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..cfefe6cebc 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -4,8 +4,26 @@ object TestConstants { const val TOTAL_BALANCE = "$3,299.18" const val RECIPIENT_ADDRESS = "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0" + const val ETHEREUM_ADDRESS = "0x9FFd974772bDA94D288240C1B22F367Ce75CcD7f" + const val ETHEREUM_RECIPIENT_ADDRESS = "0x5aa711F440Eb6d4361148bBD89d03464628ace84" + const val ETHEREUM_RECIPIENT_SHORTENED_ADDRESS = "0x5aa711F440Eb6d43...89d03464628ace84" 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 XRP_RECIPIENT_ADDRESS = "rNeY28BPda6jp5N5oESZzd2ZN7eMZy8jNf" + const val ENS_NAME = "louded.eth" + const val ENS_ETHEREUM_RECIPIENT_ADDRESS = "0x0211ff638298adcbdc99c177dc2e95ec69948865" + const val ENS_ETHEREUM_RECIPIENT_SHORTENED_ADDRESS = "0x0211ff63829..." + const val KUSAMA_RECIPIENT_ADDRESS = "CqNrR92Hh76vW69vDBL5iATrZoYkk9nj67iVSUbb2YHtktn" + const val AZERO_RECIPIENT_ADDRESS = "5EA4p6DZdbt2vLZySML2dG3ZsnNrenEWZHnVCScQh4iq2KZo" + const val TEZOS_RECIPIENT_ADDRESS = "tz1eBdC2JkU2bxgZssweLo6D3wCkWN12ioHW" + const val KASPA_RECIPIENT_ADDRESS = "kaspa:qypc4aywf95rken57kclwdjvycugnacstrh5uzmu0a7pjtxas366ktgj5jzu8t6" + const val XLM_NON_ACTIVATED_RECIPIENT_ADDRESS = "GAGMMENFDWASIHSVO4BPVIT3ZH3YNUIM4EJ6MUDJW46OVPNOJSQIJ22K" + const val XLM_ACTIVATED_RECIPIENT_ADDRESS = "GDKGS3UQFUNQY34P4SIAOKKGEA3NKHH6BSAXK4HIN3BZQQRBTITJLKL6" + const val XRP_NON_ACTIVATED_RECIPIENT_ADDRESS = "rvJfSnN6JzV3rhz1RRKDHnE6MYW28BaZG" + const val XRP_ACTIVATED_RECIPIENT_ADDRESS = "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH" const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L @@ -14,4 +32,7 @@ object TestConstants { const val ALLURE_LABEL_NAME = "Owner" const val ALLURE_LABEL_VALUE = "Kaspresso" + + const val USER_TOKENS_API_SCENARIO = "user_tokens_api" + const val QUOTES_API_SCENARIO = "quotes_api" } \ No newline at end of file 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/ReceiveScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/ReceiveScenarios.kt new file mode 100644 index 0000000000..57aff3cecc --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/ReceiveScenarios.kt @@ -0,0 +1,41 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.onReceiveAssetsBottomSheet +import com.tangem.screens.onTokenReceiveQrCodeBottomSheet +import com.tangem.screens.onTokenReceiveWarningBottomSheet +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.goToQrCodeBottomSheet() { + step("Assert 'Token receive warning' bottom sheet is displayed") { + onTokenReceiveWarningBottomSheet { bottomSheet.assertIsDisplayed() } + } + step("Click on 'Got it' button") { + onTokenReceiveWarningBottomSheet { gotItButton.performClick() } + } + step("Click on 'Show QR code' button") { + onReceiveAssetsBottomSheet { showQrCodeButton.clickWithAssertion() } + } +} + +fun BaseTestCase.checkQrCodeBottomSheetScenario() { + step("Assert bottom sheet with QR code title is displayed") { + onTokenReceiveQrCodeBottomSheet { title.assertIsDisplayed() } + } + step("Assert QR code is displayed") { + onTokenReceiveQrCodeBottomSheet { qrCode.assertIsDisplayed() } + } + step("Assert address title is displayed") { + onTokenReceiveQrCodeBottomSheet { addressTitle.assertIsDisplayed() } + } + step("Assert address is displayed") { + onTokenReceiveQrCodeBottomSheet { address.assertIsDisplayed() } + } + step("Assert 'Copy' button is displayed") { + onTokenReceiveQrCodeBottomSheet { copyButton.assertIsDisplayed() } + } + step("Assert 'Share' button is displayed") { + onTokenReceiveQrCodeBottomSheet { shareButton.assertIsDisplayed() } + } +} diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt new file mode 100644 index 0000000000..cd9c06dd5c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -0,0 +1,61 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.* +import com.tangem.screens.onMainScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.screens.onTokenDetailsScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkNetworkFeeBlock(currentFeeAmount: String, withFeeSelector: Boolean) { + step("Assert fee selector block is displayed") { + onSendConfirmScreen { feeSelectorBlock.assertIsDisplayed() } + } + step("Assert fee selector icon is displayed") { + onSendConfirmScreen { feeSelectorIcon.assertIsDisplayed() } + } + step("Assert fee selector title is displayed") { + onSendConfirmScreen { feeSelectorTitle.assertIsDisplayed() } + } + step("Assert fee selector tooltip icon is displayed") { + onSendConfirmScreen { feeSelectorTooltipIcon.assertIsDisplayed() } + } + step("Assert fee amount = '$currentFeeAmount'") { + onSendConfirmScreen { feeAmount.assertTextContains(currentFeeAmount) } + } + if (withFeeSelector) { + step("Assert select fee icon is displayed") { + onSendConfirmScreen { selectFeeIcon.assertIsDisplayed() } + } + } +} + +fun BaseTestCase.openSendConfirmScreen( + tokenName: String, + inputAmount: String, + recipientAddress: String +) { + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton().performClick() } + } + step("Type '$inputAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(inputAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } +} \ 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..526f7a316b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendWarningScenarios.kt @@ -0,0 +1,84 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.screens.onMainScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onTokenDetailsScreen +import io.github.kakaocup.compose.node.element.KNode +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openSendScreen(tokenName: String, mockState: String = "") { + val scenarioState = mockState.ifEmpty { tokenName } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState) + } + 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() } + } +} + +fun BaseTestCase.checkSendWarning( + title: String, + message: String, + isDisplayed: Boolean = true, + sendButtonIsDisabled: Boolean = isDisplayed, +) { + val assertDisplay = if (isDisplayed) "displayed" else "not displayed" + + step("Assert 'Send confirm screen' is displayed") { + onSendConfirmScreen { + appBarTitle.assertIsDisplayed() + } + } + step("Assert warning title is $assertDisplay") { + onSendConfirmScreen { + warningTitle(title).assertVisibility(isDisplayed) + } + } + step("Assert warning icon is $assertDisplay") { + onSendConfirmScreen { + sendWarningIcon(message).assertVisibility(isDisplayed) + } + } + step("Assert warning message is $assertDisplay") { + onSendConfirmScreen { + sendWarningMessage(message).assertVisibility(isDisplayed) + } + } + if (sendButtonIsDisabled) + 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/OperationIsUnavailableDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/OperationIsUnavailableDialogPageObject.kt new file mode 100644 index 0000000000..b4e9fd6f91 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/OperationIsUnavailableDialogPageObject.kt @@ -0,0 +1,30 @@ +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 OperationIsUnavailableDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val text: KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + hasText(getResourceString(CommonUIR.string.token_button_unavailability_generic_description)) + useUnmergedTree = true + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } +} + +internal fun BaseTestCase.onOperationIsUnavailableDialog(function: OperationIsUnavailableDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file 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/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt index 8186ff1683..2db67d9ee7 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -9,18 +9,94 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText +import com.tangem.core.ui.R as CoreUiR class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { + val addressTextFieldTitle: KNode = child { + hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD_TITLE) + useUnmergedTree = true + } + val addressTextField: KNode = child { hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD) useUnmergedTree = true } + val addressPasteButton: KNode = child { + hasTestTag(SendAddressScreenTestTags.ADDRESS_PASTE_BUTTON) + useUnmergedTree = true + } + + val clearTextFieldButton: KNode = child { + hasContentDescription(getResourceString(CoreUiR.string.common_close)) + useUnmergedTree = true + } + + val resolvedAddress: KNode = child { + hasTestTag(SendAddressScreenTestTags.RESOLVED_ADDRESS) + } + + val recentAddressesTitle: KNode = child { + hasText(getResourceString(CoreUiR.string.send_recent_transactions)) + useUnmergedTree = true + } + + fun destinationTagBlockTitle(isMemoCorrectOrEmpty: Boolean = true): KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD_TITLE) + useUnmergedTree = true + if(isMemoCorrectOrEmpty) { + hasText(getResourceString(CoreUiR.string.send_destination_tag_field)) + } else { + hasText(getResourceString(CoreUiR.string.send_memo_destination_tag_error)) + } + } + + val destinationTagTextField: KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD) + useUnmergedTree = true + } + + val destinationTagBlockText: KNode = child { + hasText( + getResourceString(CoreUiR.string.send_recipient_memo_footer_v2) + "\n" + + getResourceString(CoreUiR.string.send_recipient_memo_footer_v2_highlighted) + ) + useUnmergedTree = true + } + + val destinationTagBlockCaution: KNode = child { + hasText(getResourceString(CoreUiR.string.send_recipient_memo_footer_v2_highlighted)) + } + + val destinationTagPasteButton: KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_PASTE_BUTTON) + useUnmergedTree = true + } + + val clearDestinationTagTextFieldButton: KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON) + useUnmergedTree = true + } + val nextButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.common_next))) + useUnmergedTree = true + } + + val continueButton: KNode = child { hasTestTag(BaseButtonTestTags.TEXT) - hasText(getResourceString(R.string.common_next)) + hasText(getResourceString(R.string.common_continue)) + useUnmergedTree = true + } + + fun recentAddressWithText(recipientAddress: String): KNode = child { + hasParent(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TITLE)) + hasText(recipientAddress) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt index 5821029a67..8beeddb2eb 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -2,36 +2,55 @@ 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 +import androidx.compose.ui.test.hasTestTag as withTestTag +import com.tangem.core.ui.R as CoreUiR class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { - val title: KNode = child { + val appBarTitle: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) useUnmergedTree = true } 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 { + val secondaryAmount: KNode = child { + hasTestTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT) + useUnmergedTree = true + } + + fun leaveDepositButton(amount: String): KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.send_notification_leave_button, amount))) + useUnmergedTree = true + } + + fun reduceAmountButton(amount: String): KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.send_notification_reduce_by, amount))) + useUnmergedTree = true + } + + fun warningTitle(title: String): KNode = child { hasTestTag(NotificationTestTags.TITLE) - hasText(getResourceString(CommonUiR.string.send_notification_invalid_amount_title)) + hasText(title) useUnmergedTree = true } @@ -40,34 +59,76 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } - fun minimumSendAmountErrorIcon(amount: String): KNode = child { - hasAnySibling( - withText( - getResourceString( - CommonUiR.string.send_notification_invalid_minimum_amount_text, - amount, - amount, - ) - ) - ) + fun sendWarningIcon(message: String): KNode = child { hasTestTag(NotificationTestTags.ICON) + hasAnySibling(withText(message)) useUnmergedTree = true } - fun minimumSendAmountErrorMessage( - amount: String, + fun sendWarningMessage( + message: String, ): KNode = child { hasTestTag(NotificationTestTags.MESSAGE) - hasText( - getResourceString( - CommonUiR.string.send_notification_invalid_minimum_amount_text, - amount, - amount, - ) - ) + hasText(message) useUnmergedTree = true } + fun warningMessage(messageResId: Int): KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(getResourceString(messageResId)) + useUnmergedTree = true + } + + fun warningIcon(message: String): KNode = child { + hasTestTag(NotificationTestTags.ICON) + hasAnySibling(withText(message)) + useUnmergedTree = true + } + + fun recipientAddress(recipientAddress: String): KNode = child { + hasTestTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS) + hasText(recipientAddress) + useUnmergedTree = true + } + + val blockchainAddress: KNode = child { + hasTestTag(SendConfirmScreenTestTags.BLOCKCHAIN_ADDRESS) + useUnmergedTree = true + } + + val feeSelectorBlock: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK) + } + + val feeSelectorIcon: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.ICON) + useUnmergedTree = true + } + + val feeSelectorTitle: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.TITLE) + useUnmergedTree = true + } + + val feeSelectorTooltipIcon: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.TOOLTIP_ICON) + useUnmergedTree = true + } + + val selectFeeIcon: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.SELECT_FEE_ICON) + useUnmergedTree = true + } + + val feeAmount: KNode = child { + hasParent(withTestTag(FeeSelectorBlockTestTags.FEE_AMOUNT)) + useUnmergedTree = true + } + + val refreshButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(CoreUiR.string.warning_button_refresh)) + } } 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..1e1f1da9d2 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -38,6 +38,16 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } + val equivalentInputAmount: KNode = child { + hasTestTag(SendScreenTestTags.EQUIVALENT_INPUT_AMOUNT) + useUnmergedTree = true + } + + val exchangeIcon: KNode = child { + hasTestTag(SendScreenTestTags.EXCHANGE_ICON) + useUnmergedTree = true + } + val tokenName: KNode = child { hasTestTag(SendScreenTestTags.TOKEN_NAME) useUnmergedTree = true @@ -64,6 +74,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/screens/SwapIsNotSupportedDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapIsNotSupportedDialogPageObject.kt new file mode 100644 index 0000000000..2f897e61fd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapIsNotSupportedDialogPageObject.kt @@ -0,0 +1,35 @@ +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 SwapIsNotSupportedDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + fun text(currencyName: String): KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + hasText( + getResourceString( + CommonUIR.string.token_button_unavailability_reason_not_exchangeable, + currencyName + ) + ) + useUnmergedTree = true + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } +} + +internal fun BaseTestCase.onSwapIsNotSupportedDialog(function: SwapIsNotSupportedDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 1c3fa1fbb1..dff98cfcee 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -97,25 +97,31 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide ) @OptIn(ExperimentalTestApi::class) - val swapButton: LazyListItemNode = horizontalActionChips.childWith { + fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith { + hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) + hasText(getResourceString(R.string.common_receive)) + } + + @OptIn(ExperimentalTestApi::class) + fun swapButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_swap)) } @OptIn(ExperimentalTestApi::class) - val sellButton: LazyListItemNode = horizontalActionChips.childWith { + fun sellButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_sell)) } @OptIn(ExperimentalTestApi::class) - val buyButton: LazyListItemNode = horizontalActionChips.childWith { + fun buyButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_buy)) } @OptIn(ExperimentalTestApi::class) - val sendButton: LazyListItemNode = horizontalActionChips.childWith { + fun sendButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_send)) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt index 28b46d345c..df177bb300 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt @@ -2,14 +2,23 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.CARDANO_ADDRESS +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO 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.openSendScreen import com.tangem.scenarios.synchronizeAddresses -import com.tangem.screens.* +import com.tangem.screens.onMainScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.screens.onTokenDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test @@ -26,28 +35,18 @@ class BlockchainTest : BaseTestCase() { val validSendAmount = "10" val minAmount = "ADA 1.00" val address = CARDANO_ADDRESS - val scenarioName = "user_tokens_api" - val scenarioState = "Cardano" + + val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title) + val invalidAmountMessage = + getResourceString(R.string.send_notification_invalid_minimum_amount_text, minAmount, minAmount) setupHooks( additionalAfterSection = { - resetWireMockScenarioState(scenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { - step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { - setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) - } - 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("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) } step("Type '$errorSendAmount' in input text field") { onSendScreen { @@ -64,47 +63,30 @@ 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 warning' is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + ) } - 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("Press system 'Back' button") { - device.uiDevice.pressBack() - } - step("Assert address text field is displayed") { - onSendAddressScreen { addressTextField.assertIsDisplayed() } - } - step("Press system 'Back' button") { - device.uiDevice.pressBack() + step("Click on 'Amount' field") { + onSendConfirmScreen { primaryAmount.clickWithAssertion() } } step("Type '$validSendAmount' in input text field") { onSendScreen { amountInputTextField.performClick() - amountInputTextField.performTextReplacement(errorSendAmount) + amountInputTextField.performTextReplacement(validSendAmount) } } - step("Click on 'Next' button") { - onSendScreen { nextButton.clickWithAssertion() } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } } - step("Assert address text field is displayed") { - onSendAddressScreen { addressTextField.assertIsDisplayed() } - } - 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( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) } } } @@ -136,10 +118,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 +157,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/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 210063dedb..f6cd0fc1e3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -15,7 +15,6 @@ import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.sdk.mocks.MockProvider import com.tangem.tap.store import dagger.hilt.android.testing.HiltAndroidTest -import io.qameta.allure.kotlin.Allure import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test @@ -73,7 +72,7 @@ class FeedbackTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } step("Click 'Send' button") { - onTokenDetailsScreen { sendButton.performClick() } + onTokenDetailsScreen { sendButton().performClick() } } step("Type '$sendAmount' in input text field") { onSendScreen { @@ -130,7 +129,7 @@ class FeedbackTest : BaseTestCase() { MockProvider.resetEmulateError() } ).run { - Allure.step("Click on 'Accept' button") { + step("Click on 'Accept' button") { onDisclaimerScreen { acceptButton.clickWithAssertion() } } step("Set scanning error") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt index 014b42a242..d237730598 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt @@ -47,7 +47,7 @@ class SwapTokenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton.performClick() } + onTokenDetailsScreen { swapButton().performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -131,7 +131,7 @@ class SwapTokenTest : BaseTestCase() { disableMobileData() } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton.performClick() } + onTokenDetailsScreen { swapButton().performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -175,7 +175,7 @@ class SwapTokenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton.performClick() } + onTokenDetailsScreen { swapButton().performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } 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..5003b74bcd 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -2,14 +2,19 @@ package com.tangem.tests.actionButtons import androidx.compose.ui.test.longClick import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig import com.tangem.common.constants.TestConstants.BITCOIN_ADDRESS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.utils.assertClipboardTextEquals -import com.tangem.common.utils.clearClipboard -import com.tangem.scenarios.openMainScreen -import com.tangem.scenarios.synchronizeAddresses +import com.tangem.common.extensions.* +import com.tangem.common.utils.* +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.scenarios.* 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 @@ -281,41 +286,18 @@ class MainScreenActionButtonsTest : BaseTestCase() { step("Click on 'Receive' button") { onTokenActionsBottomSheet { receiveButton.performClick() } } - step("Assert 'Token receive warning' bottom sheet is displayed") { - waitForIdle() + step("Go to QR code bottom sheet") { flakySafely(WAIT_UNTIL_TIMEOUT) { - onTokenReceiveWarningBottomSheet { - bottomSheet.assertIsDisplayed() - } + goToQrCodeBottomSheet() } } - step("Click on 'Got it' button") { - onTokenReceiveWarningBottomSheet { gotItButton.performClick() } - } - step("Click on 'Show QR code' button") { - onReceiveAssetsBottomSheet { showQrCodeButton.clickWithAssertion() } - } - step("Assert bottom sheet with QR code title is displayed") { - onTokenReceiveQrCodeBottomSheet { title.assertIsDisplayed() } - } - step("Assert QR code is displayed") { - onTokenReceiveQrCodeBottomSheet { qrCode.assertIsDisplayed() } - } - step("Assert address title is displayed") { - onTokenReceiveQrCodeBottomSheet { addressTitle.assertIsDisplayed() } - } - step("Assert address is displayed") { - onTokenReceiveQrCodeBottomSheet { address.assertIsDisplayed() } - } - step("Assert 'Copy' button is displayed") { - onTokenReceiveQrCodeBottomSheet { copyButton.assertIsDisplayed() } - } - step("Assert 'Share' button is displayed") { - onTokenReceiveQrCodeBottomSheet { shareButton.assertIsDisplayed() } + step("Check QR code bottom sheet") { + checkQrCodeBottomSheetScenario() } } } + @ApiEnv(ApiEnvConfig(ApiConfig.ID.MoonPay, ApiEnvironment.PROD)) @AllureId("85") @DisplayName("Action buttons (long tap): check 'Sell' button") @Test @@ -342,10 +324,10 @@ class MainScreenActionButtonsTest : BaseTestCase() { } } } - step("Assert 'Receive' button is displayed") { + step("Assert 'Sell' button is displayed") { onTokenActionsBottomSheet { sellButton.assertIsDisplayed() } } - step("Click on 'Receive' button") { + step("Click on 'Sell' button") { onTokenActionsBottomSheet { sellButton.performClick() } } step("Assert Chrome Browser is opened") { @@ -391,4 +373,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() } + } + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.MoonPay, ApiEnvironment.PROD)) + @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() } + } + 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/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt new file mode 100644 index 0000000000..a531a3cc1a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -0,0 +1,255 @@ +package com.tangem.tests.actionButtons + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT +import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeVertical +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.checkQrCodeBottomSheetScenario +import com.tangem.scenarios.goToQrCodeBottomSheet +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* +import com.tangem.screens.onMainScreen +import com.tangem.screens.onSwapStoriesScreen +import com.tangem.screens.onSwapTokenScreen +import com.tangem.screens.onTokenDetailsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TokenDetailsScreenActionButtonsTest : BaseTestCase() { + + @AllureId("594") + @DisplayName("Action buttons (token details screen): validate UI") + @Test + fun actionButtonsValidateUiTest() { + val tokenTitle = "Bitcoin" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } + } + step("Assert 'Receive' button is displayed") { + onTokenDetailsScreen { receiveButton().assertIsDisplayed() } + } + step("Assert 'Buy' button is displayed") { + onTokenDetailsScreen { buyButton().assertIsDisplayed() } + } + step("Assert 'Send' button is displayed") { + onTokenDetailsScreen { sendButton().assertIsDisplayed() } + } + step("Assert 'Swap' button is displayed") { + onTokenDetailsScreen { swapButton().assertIsDisplayed() } + } + step("Assert 'Sell' button is displayed") { + onTokenDetailsScreen { sellButton().assertIsDisplayed() } + } + } + } + + @AllureId("593") + @DisplayName("Action buttons (token details screen): check buttons state") + @Test + fun checkActionButtonsStateTest() { + val tokenTitle = "Bitcoin" + val actionButtonIsNotDimmed = "Action button is not dimmed" + val actionButtonIsDimmed = "Action button is dimmed" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } + } + step("Assert 'Receive' button is not dimmed") { + onTokenDetailsScreen { receiveButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) } + } + step("Assert 'Buy' button is not dimmed") { + onTokenDetailsScreen { buyButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) } + } + step("Assert 'Send' button is dimmed") { + onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + } + step("Assert 'Swap' button is dimmed") { + onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + } + step("Assert 'Sell' button is dimmed") { + onTokenDetailsScreen { sellButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + } + } + } + + @AllureId("4459") + @DisplayName("Action buttons (token details screen): check 'Swap' button (success)") + @Test + fun checkSwapButtonSuccessTest() { + val tokenTitle = "Ethereum" + val tokenSymbol = "ETH" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } + } + step("Click on 'Swap' button") { + onTokenDetailsScreen { swapButton().performClick() } + } + step("Close 'Stories' screen") { + onSwapStoriesScreen { closeButton.clickWithAssertion() } + } + step("Assert 'Swap' screen title is displayed") { + onSwapTokenScreen { title.assertIsDisplayed() } + } + step("Assert token symbol: '$tokenSymbol' is displayed") { + onSwapTokenScreen { tokenSymbol(tokenSymbol).assertIsDisplayed() } + } + } + } + + @AllureId("4460") + @DisplayName("Action buttons (token details screen): check 'Swap' button (provider error)") + @Test + fun checkSwapButtonProviderErrorTest() { + val tokenTitle = "POL (ex-MATIC)" + val actionButtonIsDimmed = "Action button is dimmed" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } + step("Click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } + } + step("Assert 'Swap' button is dimmed") { + onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + } + step("Click on 'Swap' button") { + onTokenDetailsScreen { swapButton().performClick() } + } + step("Assert swapping $tokenTitle is not supported dialog text is displayed") { + onSwapIsNotSupportedDialog { text(tokenTitle).assertIsDisplayed() } + } + step("Assert 'Ok' button is displayed") { + onSwapIsNotSupportedDialog { okButton.assertIsDisplayed() } + } + step("Click on 'Ok' button") { + onSwapIsNotSupportedDialog { okButton.performClick() } + } + step("Assert 'Swap' button is dimmed") { + onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + } + } + } + + @AllureId("4461") + @DisplayName("Action buttons (token details screen): check 'Swap' button (Express error)") + @Test + fun checkSwapButtonExpressErrorTest() { + val tokenTitle = "Polygon" + val actionButtonIsDimmed = "Action button is dimmed" + 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("Synchronize addresses") { + synchronizeAddresses() + } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } + step("Click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } + } + step("Assert 'Swap' button is dimmed") { + onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + } + step("Click on 'Swap' button") { + onTokenDetailsScreen { swapButton().performClick() } + } + step("Assert operation is unavailable dialog text is displayed") { + onOperationIsUnavailableDialog { text.assertIsDisplayed() } + } + step("Assert 'Ok' button is displayed") { + onOperationIsUnavailableDialog { okButton.assertIsDisplayed() } + } + step("Click on 'Ok' button") { + onOperationIsUnavailableDialog { okButton.performClick() } + } + step("Assert 'Swap' button is dimmed") { + onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + } + } + } + + @AllureId("3590") + @DisplayName("Action buttons (token details screen): validate UI") + @Test + fun checkReceiveButtonTest() { + val tokenTitle = "Bitcoin" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } + } + step("Assert 'Receive' button is displayed") { + onTokenDetailsScreen { receiveButton().performClick() } + } + step("Go to QR code bottom sheet") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + goToQrCodeBottomSheet() + } + } + step("Check QR code bottom sheet") { + checkQrCodeBottomSheetScenario() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt new file mode 100644 index 0000000000..15a61d3cde --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt @@ -0,0 +1,338 @@ +package com.tangem.tests.send.addressScreen + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ENS_ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.ENS_ETHEREUM_RECIPIENT_SHORTENED_ADDRESS +import com.tangem.common.constants.TestConstants.ENS_NAME +import com.tangem.common.constants.TestConstants.ETHEREUM_ADDRESS +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_SHORTENED_ADDRESS +import com.tangem.common.constants.TestConstants.XRP_RECIPIENT_ADDRESS +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.* +import com.tangem.core.ui.R +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.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SendAddressScreenTest : BaseTestCase() { + + @AllureId("4006") + @DisplayName("Send (address screen): check address history") + @Test + fun sendAddressHistoryTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + val recipientShortenedAddress = ETHEREUM_RECIPIENT_SHORTENED_ADDRESS + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + + setupHooks().run { + 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 '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Click on previous address") { + onSendAddressScreen { recentAddressWithText(recipientShortenedAddress).clickWithAssertion() } + } + step("Assert recipient address is displayed") { + onSendConfirmScreen { recipientAddress(recipientAddress).assertIsDisplayed() } + } + step("Press system 'Back' button") { + device.uiDevice.pressBack() + } + step("Assert address text field contains correct recipient address") { + onSendAddressScreen { addressTextField.assertTextContains(recipientAddress) } + } + step("Assert 'Recent' title is displayed") { + onSendAddressScreen { recentAddressesTitle.assertIsNotDisplayed() } + } + step("Assert 'Next' button is enabled") { + onSendAddressScreen { nextButton.assertIsEnabled() } + } + step("Click on 'Cross' button") { + onSendAddressScreen { clearTextFieldButton.clickWithAssertion() } + } + step("Assert recipient text field is empty") { + onSendAddressScreen { addressTextField.assertTextEquals("") } + } + step("Assert 'Recent' title is displayed") { + onSendAddressScreen { recentAddressesTitle.assertIsDisplayed() } + } + step("Assert 'Next' button is disabled") { + onSendAddressScreen { nextButton.assertIsNotEnabled() } + } + } + } + + @AllureId("4004") + @DisplayName("Send (address screen): check destination tag") + @Test + fun sendDestinationTagTest() { + val tokenName = "XRP Ledger" + val sendAmount = "1" + val correctMemo = "123" + val invalidMemo = "hz" + val xrpRecipientAddress = XRP_RECIPIENT_ADDRESS + val userTokensScenarioName = "user_tokens_api" + val userTokensScenarioState = "XRP" + val context = device.context + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenarioName) + } + ).run { + step("Set WireMock scenario: '$userTokensScenarioName' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton().performClick() } + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Set clipboard text") { + setClipboardText(context, correctMemo) + } + step("Assert 'Destination Tag' title is displayed") { + onSendAddressScreen { destinationTagBlockTitle().assertIsDisplayed() } + } + step("Assert 'Destination Tag' text is displayed") { + onSendAddressScreen { destinationTagBlockText.assertIsDisplayed() } + } + step("CLick on 'Paste' button") { + onSendAddressScreen { destinationTagPasteButton.clickWithAssertion() } + } + step("Assert 'Next' button is disabled") { + onSendAddressScreen { nextButton.assertIsNotEnabled() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(xrpRecipientAddress) } + } + step("Assert 'Next' button is enabled") { + onSendAddressScreen { nextButton.assertIsEnabled() } + } + step("Click on 'Clear text field button'") { + onSendAddressScreen { clearDestinationTagTextFieldButton.clickWithAssertion() } + } + step("Type invalid memo in input text field") { + onSendAddressScreen { destinationTagTextField.performTextReplacement(invalidMemo) } + } + step("Assert 'Invalid memo' title is displayed") { + onSendAddressScreen { destinationTagBlockTitle(isMemoCorrectOrEmpty = false).assertIsDisplayed() } + } + step("Assert 'Next' button is disabled") { + onSendAddressScreen { nextButton.assertIsNotEnabled() } + } + } + } + + @AllureId("4543") + @DisplayName("Send (address screen): check address field") + @Test + fun sendAddressFieldTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + val invalidAddress = "s" + val walletAddress = ETHEREUM_ADDRESS + val context = device.context + val recipient = getResourceString(R.string.send_recipient) + val notAValidAddress = getResourceString(R.string.send_recipient_address_error) + val sameAsWalletAddress = getResourceString(R.string.send_error_address_same_as_wallet) + + setupHooks( + additionalAfterSection = { + clearClipboard() + } + ).run { + 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 '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Set clipboard text") { + setClipboardText(context, recipientAddress) + } + step("Click on 'Paste' button") { + onSendAddressScreen { addressPasteButton.clickWithAssertion() } + } + step("Assert address text field contains correct recipient address") { + onSendAddressScreen { addressTextField.assertTextContains(recipientAddress) } + } + step("Assert address text field title is displayed") { + onSendAddressScreen { addressTextFieldTitle.assertTextContains(recipient) } + } + step("Assert 'Next' button is enabled") { + onSendAddressScreen { nextButton.assertIsEnabled() } + } + step("Set clipboard text") { + setClipboardText(context, invalidAddress) + } + step("Click on 'Cross' button") { + onSendAddressScreen { clearTextFieldButton.clickWithAssertion() } + } + step("Click on 'Paste' button") { + onSendAddressScreen { addressPasteButton.clickWithAssertion() } + } + step("Assert address text field contains invalid address") { + onSendAddressScreen { addressTextField.assertTextContains(invalidAddress) } + } + step("Assert invalid address text field title is displayed") { + onSendAddressScreen { addressTextFieldTitle.assertTextContains(notAValidAddress) } + } + step("Assert 'Next' button is disabled") { + onSendAddressScreen { nextButton.assertIsNotEnabled() } + } + step("Set clipboard text") { + setClipboardText(context, walletAddress) + } + step("Click on 'Cross' button") { + onSendAddressScreen { clearTextFieldButton.clickWithAssertion() } + } + step("Click on 'Paste' button") { + onSendAddressScreen { addressPasteButton.clickWithAssertion() } + } + step("Assert address text field contains invalid address") { + onSendAddressScreen { addressTextField.assertTextContains(walletAddress) } + } + step("Assert 'Address is the same as wallet address' error title is displayed") { + onSendAddressScreen { addressTextFieldTitle.assertTextContains(sameAsWalletAddress) } + } + step("Assert 'Next' button is disabled") { + onSendAddressScreen { nextButton.assertIsNotEnabled() } + } + } + } + + @AllureId("4004") + @DisplayName("Send (address screen): check ENS name") + @Test + fun sendEnsNameTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + val ensName = ENS_NAME + val invalidEnsName = "l" + val ensAddress = ENS_ETHEREUM_RECIPIENT_ADDRESS + val ensShortenedAddress = ENS_ETHEREUM_RECIPIENT_SHORTENED_ADDRESS + val scenarioName = "eth_call_api" + val scenarioState = "EnsName" + val notAValidAddress = getResourceString(R.string.send_recipient_address_error) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton().performClick() } + } + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type ENS name: '$ensName' in text field") { + onSendAddressScreen { addressTextField.performTextReplacement(ensName) } + } + step("Assert ENS address displayed") { + onSendAddressScreen { resolvedAddress.assertTextContains(ensAddress, substring = true) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert recipient address is displayed") { + onSendConfirmScreen { recipientAddress(ensName).assertIsDisplayed() } + } + step("Assert blockchain address is displayed") { + onSendConfirmScreen { blockchainAddress.assertTextContains(ensShortenedAddress, substring = true) } + } + step("Click on recipient address") { + onSendConfirmScreen { recipientAddress(ensName).performClick() } + } + step("Click on 'Cross' button") { + onSendAddressScreen { clearTextFieldButton.clickWithAssertion() } + } + step("Type invalid ENS name: '$invalidEnsName' in text field") { + onSendAddressScreen { addressTextField.performTextReplacement(invalidEnsName) } + } + step("Assert invalid address error title is displayed") { + onSendAddressScreen { addressTextFieldTitle.assertTextContains(notAValidAddress) } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt new file mode 100644 index 0000000000..5a89153081 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt @@ -0,0 +1,300 @@ +package com.tangem.tests.send.confirmScreen + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.* +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SendConfirmScreenTest : BaseTestCase() { + + @AllureId("4003") + @DisplayName("Send (Confirm screen): change sending amount") + @Test + fun changeSendingAmountTest() { + val tokenName = "Ethereum" + val inputAmount = "1" + val newInputAmount = "0.9" + val ethereumAmount = "1.00" + val fiatAmount = "$2,535.63" + val newFiatAmount = "$2,282.07" + val newEthereumAmount = "0.90" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + + setupHooks().run { + 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 '$inputAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(inputAmount) + } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(fiatAmount) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert token amount = '$ethereumAmount'") { + onSendConfirmScreen { primaryAmount.assertTextContains(ethereumAmount) } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendConfirmScreen { secondaryAmount.assertTextContains(fiatAmount) } + } + step("Click on token amount") { + onSendConfirmScreen { primaryAmount.performClick() } + } + step("Clear input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextClearance() + } + } + step("Type '$newInputAmount' in input text field") { + onSendScreen { + amountInputTextField.performTextReplacement(newInputAmount) + } + } + step("Assert fiat amount = '$newFiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(newFiatAmount) } + } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert token amount = '$newEthereumAmount'") { + onSendConfirmScreen { primaryAmount.assertTextContains(newEthereumAmount) } + } + step("Assert fiat amount = '$newFiatAmount'") { + onSendConfirmScreen { secondaryAmount.assertTextContains(newFiatAmount) } + } + } + } + + @AllureId("552") + @DisplayName("Send (Confirm screen): switch to equivalent") + @Test + fun switchToEquivalentTest() { + val tokenName = "Ethereum" + val inputAmount = "1" + val tokenAmount = "1.00" + val ethereumAmount = "ETH 1.00" + val fiatAmount = "$2,535.63" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + + setupHooks().run { + 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 '$inputAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(inputAmount) + } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(fiatAmount) } + } + step("Click on exchange icon") { + onSendScreen { exchangeIcon.performClick() } + } + step("Assert token amount = '$ethereumAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(ethereumAmount) } + } + step("Click on exchange icon") { + onSendScreen { exchangeIcon.performClick() } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(fiatAmount) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert primary amount = '$tokenAmount'") { + onSendConfirmScreen { primaryAmount.assertTextContains(tokenAmount) } + } + step("Assert secondary amount = '$fiatAmount'") { + onSendConfirmScreen { secondaryAmount.assertTextContains(fiatAmount) } + } + } + } + + @AllureId("553") + @DisplayName("Send (Confirm screen): check fee for blockchain with unknown fee") + @Test + fun checkFeeForBlockchainWithUnknownFeeTest() { + val tokenName = "Polygon" + val inputAmount = "1" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + val currentFeeAmount = "<$0.01" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Swipe up to token") { + swipeVertical(SwipeDirection.UP) + } + step("Open 'Send Confirm' screen for token '$tokenName'") { + openSendConfirmScreen( + tokenName = tokenName, + inputAmount = inputAmount, + recipientAddress = recipientAddress + ) + } + step("Check network fee block") { + checkNetworkFeeBlock(currentFeeAmount = currentFeeAmount, withFeeSelector = true) + } + } + } + + @AllureId("4565") + @DisplayName("Send (Confirm screen): check fee for token with unknown fee") + @Test + fun checkFeeForTokenWithUnknownFeeTest() { + val tokenName = "POL (ex-MATIC)" + val inputAmount = "1" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + val currentFeeAmount = "<$0.01" + val scenarioName = "eth_estimate_gas" + val scenarioState = "UnknownFee" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Swipe up to token") { + swipeVertical(SwipeDirection.UP) + } + step("Open 'Send Confirm' screen for token '$tokenName'") { + openSendConfirmScreen( + tokenName = tokenName, + inputAmount = inputAmount, + recipientAddress = recipientAddress + ) + } + step("Check network fee block") { + checkNetworkFeeBlock(currentFeeAmount = currentFeeAmount, withFeeSelector = true) + } + } + } + + @AllureId("554") + @DisplayName("Send (Confirm screen): check fee warning") + @Test + fun checkFeeWarningTest() { + val tokenName = "Polkadot" + val tokenAmount = "0.1" + val warningTitle = getResourceString(R.string.send_fee_unreachable_error_title) + val warningMessageResId = R.string.send_fee_unreachable_error_text + val currentFeeAmount = "$0.05" + + setupHooks( + additionalAfterSection = { + enableWiFi() + enableMobileData() + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$tokenAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(tokenAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) } + } + step("Turn off internet") { + disableWiFi() + disableMobileData() + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Turn on internet") { + enableWiFi() + enableMobileData() + } + step("Assert 'Network fee info unreachable' warning title is displayed") { + onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() } + } + step("Assert 'Check your internet connection' warning message is displayed") { + onSendConfirmScreen { warningMessage(warningMessageResId).assertIsDisplayed() } + } + step("Assert warning icon is displayed") { + onSendConfirmScreen { warningIcon(warningTitle).assertIsDisplayed() } + } + step("Click on 'Refresh' button") { + waitForIdle() + onSendConfirmScreen { refreshButton.clickWithAssertion() } + } + step("Check network fee block") { + checkNetworkFeeBlock(currentFeeAmount = currentFeeAmount, withFeeSelector = false) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/AzeroWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/AzeroWarningsTest.kt new file mode 100644 index 0000000000..3e4c62024e --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/AzeroWarningsTest.kt @@ -0,0 +1,98 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.AZERO_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class AzeroWarningsTest : BaseTestCase() { + private val tokenName = "Aleph Zero" + private val amountToLeaveLessThanDeposit = "0.099876587544" + private val amountToLeaveGreaterThanDeposit = "0.09" + private val depositAmount = "AZERO 0.0000000005" + + private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title) + private val warningMessage = getResourceString( + R.string.send_notification_existential_deposit_text, depositAmount + ) + + @AllureId("4290") + @DisplayName("Warnings: check deposit warnings in Azero") + @Test + fun checkWarning() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveLessThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveLessThanDeposit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(AZERO_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + ) + } + step("Click on 'Leave $depositAmount' button") { + onSendConfirmScreen { leaveDepositButton(depositAmount).clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Amount' field") { + onSendConfirmScreen { primaryAmount.clickWithAssertion() } + } + step("Type '$amountToLeaveGreaterThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveGreaterThanDeposit) + } + } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KusamaWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KusamaWarningsTest.kt new file mode 100644 index 0000000000..c545a4100b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KusamaWarningsTest.kt @@ -0,0 +1,98 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.KUSAMA_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class KusamaWarningsTest : BaseTestCase() { + private val tokenName = "Kusama" + private val amountToLeaveLessThanDeposit = "0.300333" + private val amountToLeaveGreaterThanDeposit = "0.1" + private val depositAmount = "KSM 0.000333333333" + + private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title) + private val warningMessage = getResourceString( + R.string.send_notification_existential_deposit_text, depositAmount + ) + + @AllureId("4291") + @DisplayName("Warnings: check deposit warnings in Kusama") + @Test + fun checkWarning() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveLessThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveLessThanDeposit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KUSAMA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + ) + } + step("Click on 'Leave $depositAmount' button") { + onSendConfirmScreen { leaveDepositButton(depositAmount).clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Amount' field") { + onSendConfirmScreen { primaryAmount.clickWithAssertion() } + } + step("Type '$amountToLeaveGreaterThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveGreaterThanDeposit) + } + } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/PolkadotWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/PolkadotWarningsTest.kt new file mode 100644 index 0000000000..be40b64a78 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/PolkadotWarningsTest.kt @@ -0,0 +1,98 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class PolkadotWarningsTest : BaseTestCase() { + private val tokenName = "Polkadot" + private val amountToLeaveLessThanDeposit = "1.299" + private val amountToLeaveGreaterThanDeposit = "0.2" + private val depositAmount = "DOT 1.00" + + private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title) + private val warningMessage = getResourceString( + R.string.send_notification_existential_deposit_text, depositAmount + ) + + @AllureId("4289") + @DisplayName("Warnings: check deposit warnings in Polkadot") + @Test + fun checkWarning() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveLessThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveLessThanDeposit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage + ) + } + step("Click on 'Leave $depositAmount' button") { + onSendConfirmScreen { leaveDepositButton(depositAmount).clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Amount' field") { + onSendConfirmScreen { primaryAmount.clickWithAssertion() } + } + step("Type '$amountToLeaveGreaterThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveGreaterThanDeposit) + } + } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = 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..ec60a4de62 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt @@ -0,0 +1,180 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SolanaWarningsTest : BaseTestCase() { + private val tokenName = "Solana" + private val amountToLeaveLessThanRent = "0.0016941" + private val amountToLeaveGreaterThanRent = "0.0000941" + private val amountToLeaveRentOnly = "0.00168934" + private val rentAmount = "SOL 0.00089088" + + private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title) + private val invalidAmountMessage = getResourceString(R.string.send_notification_invalid_amount_rent_fee, rentAmount) + + @AllureId("564") + @DisplayName("Warnings: warning is displayed, if after send balance is less than rent amount (SOLANA)") + @Test + fun warningIsDisplayedWhenLeaveLessThanRent() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + 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( + title = invalidAmountTitle, + message = invalidAmountMessage + ) + } + } + } + + @AllureId("567") + @DisplayName("Warnings: warning is not displayed, if after send balance is greater than rent amount (SOLANA)") + @Test + fun warningIsNotDisplayedWhenLeaveGreaterThanRent() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + 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( + title = invalidAmountTitle, + message = invalidAmountMessage, + 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(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + 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( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("565") + @DisplayName("Warnings: warning is not displayed, if after send balance is zero (SOLANA)") + @Test + fun warningIsNotDisplayedWhenLeaveZeroSol() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + 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( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt new file mode 100644 index 0000000000..fb4483da57 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt @@ -0,0 +1,195 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.XLM_ACTIVATED_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.XLM_NON_ACTIVATED_RECIPIENT_ADDRESS +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class StellarWarningsTest : BaseTestCase() { + private val tokenName = "Stellar" + private val mockStateName = "XLM" + private val lessThanReserveAmount = "0.5" + private val equalToReserveAmount = "1" + private val reserveAmount = "XLM 1.00" + private val greaterThanReserveAmount = "2" + + private val warningTitle = + getResourceString(R.string.send_notification_invalid_reserve_amount_title, reserveAmount) + private val warningMessage = getResourceString(R.string.send_notification_invalid_reserve_amount_text) + + @AllureId("4287") + @DisplayName("Warnings: check warning, when sending less than reserve") + @Test + fun checkWarningWhenSendingLessThanReserve() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$lessThanReserveAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(lessThanReserveAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type non activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage + ) + } + step("Click on 'Address' field") { + onSendConfirmScreen { recipientAddress(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS).clickWithAssertion() } + } + step("Type an activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XLM_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4286") + @DisplayName("Warnings: check warning when sending amount equal to reserve") + @Test + fun checkWarningWhenSendingAmountEqualToReserve() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$equalToReserveAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(equalToReserveAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type non activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Address' field") { + onSendConfirmScreen { recipientAddress(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS).clickWithAssertion() } + } + step("Type an activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XLM_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4288") + @DisplayName("Warnings: check warning when sending greater than reserve") + @Test + fun checkWarningWhenSendingGreaterThanReserve() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$greaterThanReserveAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(greaterThanReserveAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type non activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Address' field") { + onSendConfirmScreen { recipientAddress(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS).clickWithAssertion() } + } + step("Type an activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XLM_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/TezosWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/TezosWarningsTest.kt new file mode 100644 index 0000000000..4b65dfa5e8 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/TezosWarningsTest.kt @@ -0,0 +1,120 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.TEZOS_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeVertical +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class TezosWarningsTest : BaseTestCase() { + private val tokenName = "Tezos" + private val reduceAmount = "0.000001" + private val sendAmount = "0.01" + + private val feeIsHighTitle = getResourceString(R.string.send_notification_high_fee_title) + private val feeIsHighMessage = + getResourceString(R.string.send_notification_high_fee_text, tokenName, reduceAmount) + + @AllureId("4229") + @DisplayName("Warnings: warning is displayed when sending max amount") + @Test + fun warningIsDisplayedWhenSendMaxAmount() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Click 'Max amount' button") { + onSendScreen { + maxButton.clickWithAssertion() + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(TEZOS_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Swipe up to see the warning") { + swipeVertical(SwipeDirection.UP) + } + step("Assert 'Fee is high warning' is displayed") { + checkSendWarning( + title = feeIsHighTitle, + message = feeIsHighMessage, + sendButtonIsDisabled = false + ) + } + step("Click on 'Reduce' button") { + onSendConfirmScreen { reduceAmountButton(reduceAmount).clickWithAssertion() } + } + step("Assert 'Fee is high warning' is not displayed") { + checkSendWarning( + title = feeIsHighTitle, + message = feeIsHighMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4230") + @DisplayName("Warnings: warning is not displayed when sending not max amount") + @Test + fun warningIsNotDisplayedWhenSendNotMaxAmount() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(TEZOS_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Fee is high warning' is not displayed") { + checkSendWarning( + title = feeIsHighTitle, + message = feeIsHighMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/XRPWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/XRPWarningsTest.kt new file mode 100644 index 0000000000..bc665f8a2d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/XRPWarningsTest.kt @@ -0,0 +1,195 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.XRP_ACTIVATED_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.XRP_NON_ACTIVATED_RECIPIENT_ADDRESS +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class XRPWarningsTest : BaseTestCase() { + private val tokenName = "XRP Ledger" + private val mockStateName = "XRP" + private val lessThanReserveAmount = "0.5" + private val equalToReserveAmount = "1" + private val reserveAmount = "XRP 1.00" + private val greaterThanReserveAmount = "2" + + private val warningTitle = + getResourceString(R.string.send_notification_invalid_reserve_amount_title, reserveAmount) + private val warningMessage = getResourceString(R.string.send_notification_invalid_reserve_amount_text) + + @AllureId("4255") + @DisplayName("Warnings: check warning, when sending less than reserve") + @Test + fun checkWarningWhenSendingLessThanReserve() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$lessThanReserveAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(lessThanReserveAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type non activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XRP_NON_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage + ) + } + step("Click on 'Address' field") { + onSendConfirmScreen { recipientAddress(XRP_NON_ACTIVATED_RECIPIENT_ADDRESS).clickWithAssertion() } + } + step("Type an activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XRP_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4285") + @DisplayName("Warnings: check warning when sending amount equal to reserve") + @Test + fun checkWarningWhenSendingAmountEqualToReserve() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$equalToReserveAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(equalToReserveAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type non activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XRP_NON_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Address' field") { + onSendConfirmScreen { recipientAddress(XRP_NON_ACTIVATED_RECIPIENT_ADDRESS).clickWithAssertion() } + } + step("Type an activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XRP_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4284") + @DisplayName("Warnings: check warning when sending greater than reserve") + @Test + fun checkWarningWhenSendingGreaterThanReserve() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$greaterThanReserveAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(greaterThanReserveAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type non activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XRP_NON_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Address' field") { + onSendConfirmScreen { recipientAddress(XRP_NON_ACTIVATED_RECIPIENT_ADDRESS).clickWithAssertion() } + } + step("Type an activated address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(XRP_ACTIVATED_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Invalid reserve amount warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 882377e5e5..1603427b46 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -310,4 +310,8 @@ + + + + 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/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index ce88071554..ec16f63f50 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -2,8 +2,13 @@ package com.tangem.tap.data import android.content.Context import com.squareup.moshi.Moshi -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -11,7 +16,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton -import kotlin.text.encodeToByteArray private const val DEFAULT_KEY = "tangem_pay_default_key" private const val ORDER_ID_KEY = "tangem_pay_order_id_key" @@ -19,7 +23,9 @@ private const val ORDER_ID_KEY = "tangem_pay_order_id_key" @Singleton internal class DefaultTangemPayStorage @Inject constructor( @ApplicationContext applicationContext: Context, + @NetworkMoshi moshi: Moshi, private val dispatcherProvider: CoroutineDispatcherProvider, + private val appPreferencesStore: AppPreferencesStore, ) : TangemPayStorage { private val secureStorage by lazy { @@ -29,14 +35,21 @@ internal class DefaultTangemPayStorage @Inject constructor( name = "tangem_pay_storage", ) } - private val moshi by lazy { - Moshi.Builder() - .add(KotlinJsonAdapterFactory()) - .build() - } private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } + override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) { + withContext(dispatcherProvider.io) { + secureStorage.store(key = createCustomerAddressKey(userWalletId), value = customerWalletAddress) + } + } + + override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? { + return withContext(dispatcherProvider.io) { + secureStorage.getAsString(createCustomerAddressKey(userWalletId)) + } + } + override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) { val json = tokensAdapter.toJson(tokens) @@ -67,14 +80,33 @@ internal class DefaultTangemPayStorage @Inject constructor( secureStorage.get(createOrderIdKey(customerWalletAddress))?.decodeToString(throwOnInvalidSequence = true) } + override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean { + return withContext(dispatcherProvider.io) { + appPreferencesStore.getSyncOrNull( + key = PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), + ) == true + } + } + + override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) { + withContext(dispatcherProvider.io) { + appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), isDone) + } + } + override suspend fun clearOrderId(customerWalletAddress: String) = withContext(dispatcherProvider.io) { secureStorage.delete(createOrderIdKey(customerWalletAddress)) } - override suspend fun clearAll(customerWalletAddress: String) = withContext(dispatcherProvider.io) { - secureStorage.delete(createKey(customerWalletAddress)) - secureStorage.delete(createOrderIdKey(customerWalletAddress)) - } + override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) = + withContext(dispatcherProvider.io) { + secureStorage.delete(createCustomerAddressKey(userWalletId)) + secureStorage.delete(createKey(customerWalletAddress)) + secureStorage.delete(createOrderIdKey(customerWalletAddress)) + appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false) + } + + private fun createCustomerAddressKey(userWalletId: UserWalletId): String = userWalletId.stringValue private fun createKey(address: String): String = "${DEFAULT_KEY}_$address" 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 1ee8ec2a46..1f40496096 100644 --- a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt +++ b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt @@ -1,14 +1,18 @@ package com.tangem.tap.di -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader +import com.tangem.datasource.api.moonpay.MoonPayApi +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage 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.repository.CurrenciesRepository +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.scanCard.repository.DefaultScanCardRepository import com.tangem.tap.network.exchangeServices.DefaultRampManager +import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService import com.tangem.tap.proxy.AppStateHolder import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -43,17 +47,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, ) } @@ -63,4 +65,19 @@ internal object ActivityModule { fun provideActivityDelayedWorkCoroutineScope(): CoroutineScope { return CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + @Provides + @Singleton + fun provideExchangeService( + environmentConfigStorage: EnvironmentConfigStorage, + getSelectedWalletUseCase: GetSelectedWalletUseCase, + moonPayApi: MoonPayApi, + ): SellService { + return MoonPayService( + api = moonPayApi, + apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().moonPayApiKey }, + secretKeyProvider = Provider { environmentConfigStorage.getConfigSync().moonPayApiSecretKey }, + userWalletProvider = { getSelectedWalletUseCase.sync().getOrNull() }, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index d741fc1a41..0487d0fad7 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -7,6 +7,7 @@ import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager +import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.visa.VisaCardScanHandler import dagger.Module @@ -27,6 +28,7 @@ internal class TangemSdkManagerModule { cardSdkConfigRepository: CardSdkConfigRepository, visaCardScanHandler: VisaCardScanHandler, visaCardActivationTaskFactory: VisaCardActivationTask.Factory, + tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ): TangemSdkManager { return if (BuildConfig.MOCK_DATA_SOURCE) { @@ -37,6 +39,7 @@ internal class TangemSdkManagerModule { resources = context.resources, visaCardScanHandler = visaCardScanHandler, visaCardActivationTaskFactory = visaCardActivationTaskFactory, + tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, onboardingV2FeatureToggles = onboardingV2FeatureToggles, ) } 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/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index d16fc22bd8..ab440ee279 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -1,5 +1,7 @@ package com.tangem.tap.di.domain +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.nft.* import com.tangem.domain.nft.repository.NFTRepository @@ -24,9 +26,13 @@ internal object NFTDomainModule { fun providesGetNFTCollectionsUseCase( currenciesRepository: CurrenciesRepository, nftRepository: NFTRepository, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + accountsFeatureToggles: AccountsFeatureToggles, ): GetNFTCollectionsUseCase = GetNFTCollectionsUseCase( currenciesRepository = currenciesRepository, nftRepository = nftRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + accountsFeatureToggles = accountsFeatureToggles, ) @Provides @@ -60,10 +66,12 @@ internal object NFTDomainModule { @Singleton fun providesGetNFTAvailableNetworksUseCase( nftRepository: NFTRepository, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currenciesRepository: CurrenciesRepository, ): GetNFTNetworksUseCase = GetNFTNetworksUseCase( currenciesRepository = currenciesRepository, nftRepository = nftRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, ) @Provides 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 9a22983e55..c9c0d08868 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,10 +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.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 @@ -59,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( @@ -183,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/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 09a9039974..32f0e5b105 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -18,13 +18,17 @@ import com.tangem.core.res.getStringSafe import com.tangem.crypto.bip39.DefaultMnemonic import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey -import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.wallets.derivations.derivationStyleProvider +import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.visa.model.* +import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VisaActivationInput +import com.tangem.domain.visa.model.VisaDataForApprove +import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet +import com.tangem.domain.visa.model.sign +import com.tangem.domain.wallets.derivations.derivationStyleProvider import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.operations.ScanTask import com.tangem.operations.derivation.DerivationTaskResponse @@ -44,6 +48,7 @@ import com.tangem.tap.domain.tasks.product.CreateProductWalletTask import com.tangem.tap.domain.tasks.product.ResetBackupCardTask import com.tangem.tap.domain.tasks.product.ResetToFactorySettingsTask import com.tangem.tap.domain.tasks.product.ScanProductTask +import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask @@ -62,6 +67,7 @@ internal class DefaultTangemSdkManager( private val resources: Resources, private val visaCardScanHandler: VisaCardScanHandler, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, + private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) : TangemSdkManager { @@ -511,6 +517,18 @@ internal class DefaultTangemSdkManager( ) } + override suspend fun tangemPayProduceInitialCredentials( + cardId: String, + ): CompletionResult { + return coroutineScope { + runTaskAsyncReturnOnMain( + runnable = tangemPayChallengeTaskFactory.create(coroutineScope = this), + cardId = cardId, + initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)), + ) + } + } + // endregion companion object { diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index 1568e7dfc8..d86ebd543b 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -213,5 +213,11 @@ class MockTangemSdkManager( error("Not implemented") } + override suspend fun tangemPayProduceInitialCredentials( + cardId: String, + ): CompletionResult { + error("Not implemented") + } + // endregion } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index 23afd7ef06..3d66410898 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 @@ -1,7 +1,11 @@ package com.tangem.tap.domain.sdk.mocks.content import com.tangem.common.SuccessResponse -import com.tangem.common.card.* +import com.tangem.common.card.Card +import com.tangem.common.card.CardWallet +import com.tangem.common.card.EllipticCurve +import com.tangem.common.card.EncryptionMode +import com.tangem.common.card.FirmwareVersion import com.tangem.common.extensions.ByteArrayKey import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey @@ -125,10 +129,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), @@ -235,6 +247,20 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/1729'/0'/0'") to ExtendedPublicKey( // Tezos + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/111111'/0'/0/0") to ExtendedPublicKey( // Kaspa + 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 +270,49 @@ 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), + childNumber = 0, + ), + DerivationPath("m/44'/354'/0'/0'/0'") to ExtendedPublicKey( // Polkadot + 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'/434'/0'/0'/0'") to ExtendedPublicKey( // Kusama + 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'/643'/0'/0'/0'") to ExtendedPublicKey( // Azero + 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'/148'/0'") to ExtendedPublicKey( // Stellar + 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), @@ -296,9 +357,16 @@ object WalletMockContent : MockContent { 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), - 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'/1729'/0'/0'") to ExtendedPublicKey( // Tezos + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/111111'/0'/0/0") to ExtendedPublicKey( // Kaspa + 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, @@ -312,19 +380,54 @@ 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, + ), + DerivationPath("m/44'/354'/0'/0'/0'") to ExtendedPublicKey( // Polkadot + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/434'/0'/0'/0'") to ExtendedPublicKey( // Kusama + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/643'/0'/0'/0'") to ExtendedPublicKey( // Azero + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/148'/0'") to ExtendedPublicKey( // Stellar + 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/domain/tasks/visa/TangemPayGenerateAddressAndSignChallengeTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateAddressAndSignChallengeTask.kt new file mode 100644 index 0000000000..000ae515ec --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateAddressAndSignChallengeTask.kt @@ -0,0 +1,130 @@ +package com.tangem.tap.domain.tasks.visa + +import arrow.core.getOrElse +import com.tangem.common.CompletionResult +import com.tangem.common.card.CardWallet +import com.tangem.common.card.EllipticCurve +import com.tangem.common.core.CardSession +import com.tangem.common.core.CardSessionRunnable +import com.tangem.common.core.CompletionCallback +import com.tangem.common.core.TangemSdkError +import com.tangem.core.error.ext.tangemError +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.error.VisaActivationError +import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet +import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet +import com.tangem.domain.visa.model.sign +import com.tangem.operations.derivation.DeriveWalletPublicKeyTask +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class TangemPayGenerateAddressAndSignChallengeTask @AssistedInject constructor( + @Assisted private val coroutineScope: CoroutineScope, + private val dispatchersProvider: CoroutineDispatcherProvider, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, +) : CardSessionRunnable { + + override fun run(session: CardSession, callback: CompletionCallback) { + coroutineScope.launch { + callback(runSuspend(session = session)) + } + } + + private suspend fun runSuspend(session: CardSession): CompletionResult { + val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } + ?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError) + + val derivationResult = runDerivationTask(session, wallet) + val address = when (derivationResult) { + is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error) + is CompletionResult.Success -> generateAddressFromExtendedKey(derivationResult.data) + } + + val challenge = withContext(dispatchersProvider.io) { + visaAuthRemoteDataSource.getCustomerWalletAuthChallenge(address) + }.getOrElse { return CompletionResult.Failure(it.tangemError) } + + val dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge) + val approveResult = runVisaCustomerWalletApproveTask( + session = session, + cardId = card.cardId, + targetAddress = address, + dataToSign = dataToSign, + ) + val signedData = when (approveResult) { + is CompletionResult.Failure<*> -> return CompletionResult.Failure(approveResult.error) + is CompletionResult.Success -> approveResult.data + } + + val authTokens = withContext(dispatchersProvider.io) { + visaAuthRemoteDataSource.getTokenWithCustomerWallet( + sessionId = challenge.session.sessionId, + signature = signedData.signature, + nonce = signedData.dataToSign.hashToSign, + ) + }.getOrNull() ?: return CompletionResult.Failure(VisaActivationError.FailedRemoteState.tangemError) + + return CompletionResult.Success( + data = TangemPayInitialCredentials( + customerWalletAddress = address, + authTokens = authTokens, + ), + ) + } + + private suspend fun runDerivationTask( + session: CardSession, + wallet: CardWallet, + ): CompletionResult { + val deferred = CompletableDeferred>() + val derivationTask = DeriveWalletPublicKeyTask( + walletPublicKey = wallet.publicKey, + derivationPath = VisaUtilities.customDerivationPath, + ) + + derivationTask.run(session = session, callback = deferred::complete) + return deferred.await() + } + + private suspend fun runVisaCustomerWalletApproveTask( + session: CardSession, + cardId: String, + targetAddress: String, + dataToSign: VisaDataToSignByCustomerWallet, + ): CompletionResult { + val deferred = CompletableDeferred>() + val task = VisaCustomerWalletApproveTask( + visaDataForApprove = VisaCustomerWalletApproveTask.Input( + cardId = cardId, + targetAddress = targetAddress, + hashToSign = dataToSign.hashToSign, + sign = dataToSign::sign, + ), + ) + task.run(session = session, callback = deferred::complete) + return deferred.await() + } + + private fun generateAddressFromExtendedKey(extendedPublicKey: ExtendedPublicKey): String { + val derivationData = VisaUtilities.visaBlockchain.makeAddressesFromExtendedPublicKey( + extendedPublicKey = extendedPublicKey, + cachedIndex = null, + ) + return derivationData.address + } + + @AssistedFactory + interface Factory { + fun create(coroutineScope: CoroutineScope): TangemPayGenerateAddressAndSignChallengeTask + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt index 64fc98c01d..3439d72093 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt @@ -16,7 +16,6 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.error.ext.tangemError import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey -import com.tangem.domain.wallets.derivations.derivationStyleProvider import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.card.common.visa.VisaWalletPublicKeyUtility import com.tangem.domain.card.common.visa.VisaWalletPublicKeyUtility.findKeyWithoutDerivation @@ -59,21 +58,7 @@ class VisaCustomerWalletApproveTask( session: CardSession, callback: CompletionCallback, ) { - val cardDTO = CardDTO(card) - - val derivationStyle = cardDTO.derivationStyleProvider.getDerivationStyle() ?: run { - proceedApproveWithLegacyCard( - card = card, - session = session, - callback = callback, - ) - return - } - - val derivationPath = VisaUtilities.visaDefaultDerivationPath(derivationStyle) ?: run { - callback(CompletionResult.Failure(VisaActivationError.FailedToCreateAddress.tangemError)) - return - } + val derivationPath = VisaUtilities.customDerivationPath val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: run { callback(CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError)) diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 4afa4ca1bb..ddc7090242 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -16,14 +16,11 @@ import com.tangem.core.ui.message.BottomSheetMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage import com.tangem.datasource.api.common.config.managers.ApiConfigsManager -import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.domain.appcurrency.FetchAppCurrenciesUseCase import com.tangem.domain.balancehiding.BalanceHidingSettings import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase -import com.tangem.domain.common.LogConfig import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.GetApplicationIdUseCase import com.tangem.domain.notifications.SendPushTokenUseCase @@ -42,8 +39,7 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.tap.common.extensions.setContext -import com.tangem.tap.network.exchangeServices.ExchangeService -import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService +import com.tangem.tap.network.exchangeServices.SellService import com.tangem.tap.proxy.AppStateHolder import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -83,9 +79,9 @@ internal class MainViewModel @Inject constructor( private val apiConfigsManager: ApiConfigsManager, private val multiQuoteUpdater: MultiQuoteUpdater, private val appStateHolder: AppStateHolder, - private val environmentConfigStorage: EnvironmentConfigStorage, private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val appRouterConfig: AppRouterConfig, + private val sellService: SellService, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel() { @@ -195,22 +191,12 @@ internal class MainViewModel @Inject constructor( private fun initializeOffRamp() { viewModelScope.launch { - val sellService = makeSellExchangeService(environmentConfig = environmentConfigStorage.getConfigSync()) appStateHolder.sellService = sellService sellService.update() } } - private fun makeSellExchangeService(environmentConfig: EnvironmentConfig): ExchangeService { - return MoonPayService( - apiKey = environmentConfig.moonPayApiKey, - secretKey = environmentConfig.moonPayApiSecretKey, - isLogEnabled = LogConfig.network.moonPayService, - userWalletProvider = { getSelectedWalletUseCase.sync().getOrNull() }, - ) - } - private fun observeFlips() { listenToFlipsUseCase().launchIn(viewModelScope) } 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..9b9018f1ba 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 sellService: Provider, + 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) }, @@ -96,7 +90,7 @@ internal class DefaultRampManager( return availabilityState.toReason(cryptoCurrency.name) } - override fun getSellInitializationStatus(): Flow { + override fun getSellInitializationStatus(): Flow { return sellService.invoke().initializationStatus } @@ -106,8 +100,8 @@ internal class DefaultRampManager( } } - override fun getExpressInitializationStatus(userWalletId: UserWalletId): Flow { - return expressServiceLoader.getInitializationStatus(userWalletId) + override fun getExpressInitializationStatus(userWalletId: UserWalletId): Flow { + 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/ExchangeService.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt similarity index 75% rename from app/src/main/java/com/tangem/tap/network/exchangeServices/ExchangeService.kt rename to app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt index 88ba965edc..82aa59fe63 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/ExchangeService.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt @@ -5,11 +5,11 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.tap.domain.model.Currency import kotlinx.coroutines.flow.StateFlow -typealias ExchangeServiceInitializationStatus = Lce +typealias SellServiceInitializationStatus = Lce -interface ExchangeService { +interface SellService { - val initializationStatus: StateFlow + val initializationStatus: StateFlow suspend fun update() diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt index 1f291b109b..0e5af5e7c0 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt @@ -5,7 +5,9 @@ import android.util.Base64 import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.services.Result import com.tangem.common.services.performRequest -import com.tangem.datasource.api.common.createRetrofitInstance +import com.tangem.datasource.api.moonpay.MoonPayApi +import com.tangem.datasource.api.moonpay.MoonPayCurrencies +import com.tangem.datasource.api.moonpay.MoonPayUserStatus import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin import com.tangem.domain.common.extensions.withIOContext import com.tangem.domain.core.utils.lceContent @@ -14,9 +16,10 @@ import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.tap.domain.model.Currency -import com.tangem.tap.network.exchangeServices.ExchangeService -import com.tangem.tap.network.exchangeServices.ExchangeServiceInitializationStatus +import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.tap.network.exchangeServices.SellServiceInitializationStatus import com.tangem.tap.network.exchangeServices.moonpay.models.MoonPayAvailableCurrency +import com.tangem.utils.Provider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import timber.log.Timber @@ -24,25 +27,18 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec class MoonPayService( - private val apiKey: String, - private val secretKey: String, - private val isLogEnabled: Boolean, + private val api: MoonPayApi, + private val apiKeyProvider: Provider, + private val secretKeyProvider: Provider, private val userWalletProvider: () -> UserWallet?, -) : ExchangeService { +) : SellService { - override val initializationStatus: StateFlow + override val initializationStatus: StateFlow get() = _initializationStatus - private val _initializationStatus: MutableStateFlow = + private val _initializationStatus: MutableStateFlow = MutableStateFlow(value = lceLoading()) - private val api: MoonPayApi by lazy { - createRetrofitInstance( - baseUrl = MoonPayApi.MOOONPAY_BASE_URL, - logEnabled = isLogEnabled, - ).create(MoonPayApi::class.java) - } - private var status: MoonPayStatus? = null override suspend fun update() { @@ -51,7 +47,7 @@ class MoonPayService( _initializationStatus.value = lceLoading() performRequest { - val userStatus = when (val result = performRequest { api.getUserStatus(apiKey) }) { + val userStatus = when (val result = performRequest { api.getUserStatus(apiKeyProvider()) }) { is Result.Failure -> { Timber.e("Failed to load user status", result.error) _initializationStatus.value = result.error.lceError() @@ -60,7 +56,7 @@ class MoonPayService( is Result.Success -> result.data } - val currencies = when (val result = performRequest { api.getCurrencies(apiKey) }) { + val currencies = when (val result = performRequest { api.getCurrencies(apiKeyProvider()) }) { is Result.Failure -> { Timber.e("Failed to load currencies", result.error) _initializationStatus.value = result.error.lceError() @@ -78,7 +74,7 @@ class MoonPayService( MoonPayAvailableCurrency( currencyCode = currency.code, networkCode = currency.metadata?.networkCode ?: return@mapNotNull null, - contractAddress = currency.metadata.contractAddress, + contractAddress = currency.metadata?.contractAddress, ) } @@ -167,7 +163,7 @@ class MoonPayService( val uri = Uri.Builder() .scheme(SCHEME) .authority(URL_SELL) - .appendQueryParameter("apiKey", apiKey) + .appendQueryParameter("apiKey", apiKeyProvider()) .appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase()) .appendQueryParameter("refundWalletAddress", walletAddress) .appendQueryParameter("redirectURL", "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}") @@ -191,7 +187,7 @@ class MoonPayService( private fun createSignature(data: String): String { val sha256Hmac = Mac.getInstance("HmacSHA256") - val secretKey = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256") + val secretKey = SecretKeySpec(secretKeyProvider().toByteArray(), "HmacSHA256") sha256Hmac.init(secretKey) val sha256encoded = sha256Hmac.doFinal("?$data".toByteArray()) return Base64.encodeToString(sha256encoded, Base64.NO_WRAP) 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/proxy/AppStateHolder.kt b/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt index cee10eaf8f..c3261b1873 100644 --- a/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt +++ b/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt @@ -7,7 +7,7 @@ import com.tangem.tap.common.extensions.dispatchDialogShow import com.tangem.tap.common.extensions.dispatchWithMain import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.common.redux.AppState -import com.tangem.tap.network.exchangeServices.ExchangeService +import com.tangem.tap.network.exchangeServices.SellService import org.rekotlin.Action import org.rekotlin.Store import javax.inject.Inject @@ -19,7 +19,7 @@ import javax.inject.Inject class AppStateHolder @Inject constructor() : ReduxStateHolder { var mainStore: Store? = null - var sellService: ExchangeService? = null + var sellService: SellService? = null override fun dispatch(action: Action) { mainStore?.dispatch(action) 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..6a6d1f4c01 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, @@ -308,6 +320,13 @@ internal class ChildFactory @Inject constructor( userWalletId = route.userWalletId, isInitialReverseOrder = route.isInitialReverseOrder, screenSource = route.screenSource, + tangemPayInput = route.tangemPayInput?.let { tangemPayInput -> + SwapComponent.Params.TangemPayInput( + cryptoAmount = tangemPayInput.cryptoAmount, + fiatAmount = tangemPayInput.fiatAmount, + depositAddress = tangemPayInput.depositAddress, + ) + }, ), componentFactory = swapComponentFactory, ) @@ -496,6 +515,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 +559,7 @@ internal class ChildFactory @Inject constructor( context = context, params = CreateWalletBackupComponent.Params( userWalletId = route.userWalletId, + isUpgradeFlow = route.isUpgradeFlow, ), componentFactory = createWalletBackupComponentFactory, ) @@ -555,6 +582,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 +640,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/build.gradle.kts b/build.gradle.kts index 3648de0c58..b59181ab1b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,18 +34,36 @@ interface Injected { // Test Logging subprojects { - tasks.withType { + tasks.withType().configureEach { + val taskName = name.lowercase() + if (taskName.contains("external") || + taskName.contains("internal") || + taskName.contains("release") || + taskName.contains("mocked") || + taskName.contains("huawei") + ) { + enabled = false + println("Skipping test task: $name") + } else { + println("Test task scheduled: $name") + } + testLogging { exceptionFormat = TestExceptionFormat.FULL showStandardStreams = true afterSuite(KotlinClosure2({ desc, result -> if (desc.parent == null) { // will match the outermost suite - val output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" + val output = + "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" val startItem = "| " val endItem = " |" val repeatLength = startItem.length + output.length + endItem.length - println("\n" + "-".repeat(repeatLength) + "\n" + startItem + output + endItem + "\n" + "-".repeat(repeatLength)) + println( + "\n" + "-".repeat(repeatLength) + "\n" + startItem + output + endItem + "\n" + "-".repeat( + repeatLength + ) + ) } })) } 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..8358462b1e 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 @@ -15,6 +15,7 @@ import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.onramp.model.OnrampSource @@ -126,9 +127,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, } } @@ -178,13 +182,21 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, val isInitialReverseOrder: Boolean = false, val screenSource: String, + val tangemPayInput: TangemPayInput? = null, ) : AppRoute( path = "/swap" + "/${currencyFrom.id.value}" + "/${currencyTo?.id?.value}" + "/${userWalletId.stringValue}" + "/$isInitialReverseOrder", - ) + ) { + @Serializable + data class TangemPayInput( + val cryptoAmount: SerializedBigDecimal, + val fiatAmount: SerializedBigDecimal, + val depositAddress: String, + ) + } @Serializable data object AppCurrencySelector : AppRoute(path = "/app_currency_selector") @@ -192,9 +204,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 +229,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 +339,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 +361,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 +374,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 +409,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..305212c2bc 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 @@ -1,5 +1,6 @@ package com.tangem.common.ui.account +import androidx.annotation.StringRes import androidx.compose.runtime.Immutable import arrow.core.Either import arrow.core.raise.either @@ -25,7 +26,10 @@ sealed interface AccountNameUM { */ data object DefaultMain : AccountNameUM { - override val value: TextReference = resourceReference(R.string.account_main_account_title) + @get:StringRes + val stringResId: Int = R.string.account_main_account_title + + override val value: TextReference = resourceReference(stringResId) } /** @@ -33,7 +37,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..d21a35276b 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,8 @@ fun AccountTitle( accountTitleUM: AccountTitleUM, modifier: Modifier = Modifier, textStyle: TextStyle = TangemTheme.typography.subtitle2, + textColor: Color = TangemTheme.colors.text.tertiary, + iconSize: AccountIconSize = AccountIconSize.ExtraSmall, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -40,19 +43,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, + iconSize = iconSize, 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..caa4ec4ce0 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt @@ -0,0 +1,127 @@ +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.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.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, + leftContent: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier + .clickable(enabled = state.isMultiChoice, onClick = state.onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leftContent() + 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 { + + override val values: Sequence + get() = sequenceOf(PortfolioSelectRowPreviewData.account, PortfolioSelectRowPreviewData.wallet) +} + +object PortfolioSelectRowPreviewData { + 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 = {}, + ) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldV2.kt index 6360babb2d..0932e007ab 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldV2.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection @@ -47,6 +48,7 @@ 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.res.TangemThemePreview +import com.tangem.core.ui.test.SendScreenTestTags import com.tangem.core.ui.utils.rememberDecimalFormat import kotlinx.coroutines.delay @@ -191,7 +193,8 @@ private fun BoxScope.AmountFieldCurrencyInfo(amountUM: AmountState.Data, onCurre .size(16.dp) .graphicsLayer { rotationY = iconRotateState - }, + } + .testTag(SendScreenTestTags.EXCHANGE_ICON), ) AmountFieldCurrencyIcon( amountUM = amountUM, @@ -227,6 +230,7 @@ private fun AmountFieldCurrencyIcon(amountUM: AmountState.Data) { style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr), color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, + modifier = Modifier.testTag(SendScreenTestTags.EQUIVALENT_INPUT_AMOUNT), ) if (isFiatValue) { CurrencyIcon( diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index f4690823fb..9fa02703d3 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -84,6 +84,7 @@ sealed class AnalyticsParam { data object LongTap : ScreensSources("Long Tap") data object Markets : ScreensSources("Markets") data object HotWallet : ScreensSources("Hot Wallet") + data object TangemPay : ScreensSources("Tangem Pay") } sealed class TxSentFrom(val value: String) { 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/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index cac3e4c967..6b642716dd 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -27,6 +27,7 @@ sealed class ApiConfig { TangemPay, BlockAid, YieldSupply, + MoonPay, } private fun initializeId(): ID { @@ -37,6 +38,7 @@ sealed class ApiConfig { is TangemPay -> ID.TangemPay is BlockAid -> ID.BlockAid is YieldSupply -> ID.YieldSupply + is MoonPay -> ID.MoonPay } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/MoonPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/MoonPay.kt new file mode 100644 index 0000000000..0ccbacaa73 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/MoonPay.kt @@ -0,0 +1,43 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig + +/** + * MoonPay [ApiConfig] + */ +internal class MoonPay : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + createMockEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + -> ApiEnvironment.MOCK + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createProdEnvironment(): ApiEnvironmentConfig { + return ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.moonpay.com/", + ) + } + + private fun createMockEnvironment(): ApiEnvironmentConfig { + return ApiEnvironmentConfig( + environment = ApiEnvironment.MOCK, + baseUrl = "[REDACTED_ENV_URL]", + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt index b85ea4e10e..1b686d6be0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt @@ -14,10 +14,12 @@ sealed class ApiResponse { * Represents a successful response from the API * * @property data the data returned by the API + * @property code the HTTP status code of the response * @property headers the headers returned by the API */ data class Success( val data: T, + val code: ApiResponseError.HttpException.Code = ApiResponseError.HttpException.Code.OK, override val headers: Map> = emptyMap(), ) : ApiResponse() @@ -37,11 +39,20 @@ sealed class ApiResponse { * Wraps data in a [ApiResponse.Success] instance * * @param data the data to wrap + * @param code the HTTP status code of the response * @param headers the headers returned by the API * @return a [ApiResponse.Success] instance containing the provided data */ -internal fun apiSuccess(data: T, headers: Map>): ApiResponse { - return ApiResponse.Success(data, headers) +internal fun apiSuccess( + data: T, + code: ApiResponseError.HttpException.Code?, + headers: Map>, +): ApiResponse { + return ApiResponse.Success( + data = data, + code = code ?: ApiResponseError.HttpException.Code.OK, + headers = headers, + ) } /** diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt index 1028e2916f..8b818cff1b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt @@ -18,8 +18,13 @@ sealed class ApiResponseError : Exception() { val errorBody: String?, ) : ApiResponseError() { + // TODO: extract Code from HttpException // region Error Codes enum class Code(val numericCode: Int) { + // 2xx Success + OK(numericCode = 200), + CREATED(numericCode = 201), + ACCEPTED(numericCode = 202), // 3xx Server Errors NOT_MODIFIED(numericCode = 304), // 4xx Server Errors diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt index 68ad795052..ec7f06e90d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt @@ -15,11 +15,12 @@ internal fun Response.toSafeApiResponse(analyticsErrorHandler: Anal val headers = headers().toMultimap() val body = body() + val code = ApiResponseError.HttpException.Code.entries + .firstOrNull { it.numericCode == code() } + return if (isSuccessful && body != null) { - apiSuccess(data = body, headers = headers) + apiSuccess(data = body, code = code, headers = headers) } else { - val code = ApiResponseError.HttpException.Code.entries - .firstOrNull { it.numericCode == code() } val e = try { if (code == null) { ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}")) 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/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/moonpay/MoonPayApi.kt similarity index 80% rename from app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayApi.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/moonpay/MoonPayApi.kt index 554a0b4265..70e9556fe7 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/moonpay/MoonPayApi.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.network.exchangeServices.moonpay +package com.tangem.datasource.api.moonpay import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -7,17 +7,11 @@ import retrofit2.http.Query interface MoonPayApi { - @GET(MOOONPAY_IP_ADDRESS_REQUEST_URL) + @GET("v4/ip_address/") suspend fun getUserStatus(@Query("apiKey") moonPayApiKey: String): MoonPayUserStatus - @GET(MOOONPAY_CURRENCIES_REQUEST_URL) + @GET("v3/currencies/") suspend fun getCurrencies(@Query("apiKey") moonPayApiKey: String): List - - companion object { - const val MOOONPAY_BASE_URL = "https://api.moonpay.com/" - const val MOOONPAY_IP_ADDRESS_REQUEST_URL = "v4/ip_address/" - const val MOOONPAY_CURRENCIES_REQUEST_URL = "v3/currencies/" - } } @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 7622d4f825..bb456d3a78 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -7,6 +7,7 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -146,4 +147,10 @@ interface TangemPayApi { @Header("Authorization") authHeader: String, @Body body: CardDetailsRequest, ): ApiResponse + + @PUT("v1/customer/card/pin") + suspend fun setPin( + @Header("Authorization") authHeader: String, + @Body body: SetPinRequest, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinRequest.kt new file mode 100644 index 0000000000..f5052e66f0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SetPinRequest( + @Json(name = "pin") val pin: String, + @Json(name = "session_id") val sessionId: String, + @Json(name = "iv") val iv: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinResponse.kt new file mode 100644 index 0000000000..8577be889c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SetPinResponse( + @Json(name = "result") val result: Result?, + @Json(name = "error") val error: String?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "result") val result: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index e838002aa8..2ffd9c59ba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -137,6 +137,9 @@ interface TangemTechApi { @GET("v1/user-wallets/wallets/by-app/{app_id}") suspend fun getWallets(@Path("app_id") appId: String): ApiResponse> + + @POST("v1/user-wallets/wallets") + suspend fun createWallet(@Body body: OnlyWalletIdBody): ApiResponse // endregion // promo diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/OnlyWalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/OnlyWalletIdBody.kt new file mode 100644 index 0000000000..6b5be8cd81 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/OnlyWalletIdBody.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class OnlyWalletIdBody( + @Json(name = "id") val walletId: String, +) \ 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/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 83ac850e26..d126accfa7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -74,4 +74,10 @@ internal object ApiConfigsModule { fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig { return BlockAid(environmentConfigStorage) } + + @Provides + @IntoSet + fun provideMoonPayConfig(): ApiConfig { + return MoonPay() + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 9a0bed416c..22c745744a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -5,12 +5,14 @@ import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfigs +import com.tangem.datasource.api.common.config.MoonPay import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager import com.tangem.datasource.api.common.config.managers.MockApiConfigsManager import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.markets.TangemTechMarketsApi +import com.tangem.datasource.api.moonpay.MoonPayApi import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.stakekit.StakeKitApi @@ -130,4 +132,13 @@ internal object NetworkModule { applyTimeoutAnnotations = false, ) } + + @Provides + @Singleton + fun provideMoonPayApi(retrofitApiBuilder: RetrofitApiBuilder): MoonPayApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.MoonPay, + applyTimeoutAnnotations = false, + ) + } } \ 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/di/utils/RetrofitApiBuilder.kt b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt index b952dc4b5b..2c3b8604a0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt @@ -197,6 +197,7 @@ internal class RetrofitApiBuilder @Inject constructor( val excludedApiForLogging: Set = setOf( // ApiConfig.ID.StakeKit, + ApiConfig.ID.MoonPay, ) } } \ 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/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index b41be76cea..6e163fbb4c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -171,6 +171,9 @@ object PreferencesKeys { fun getHotWalletUnlockDeadlineKey(attemptId: String) = longPreferencesKey(name = "hotWalletUnlockDeadline_$attemptId") + fun getTangemPayAddToWalletKey(customerWalletAddress: String) = + booleanPreferencesKey("tangem_pay_add_to_wallet_done_key_$customerWalletAddress") + // endregion } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt index bf8bf7fa78..d74074f011 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -1,9 +1,13 @@ package com.tangem.datasource.local.visa +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.model.VisaAuthTokens interface TangemPayStorage { + suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) + suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? + suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? @@ -14,5 +18,9 @@ interface TangemPayStorage { suspend fun clearOrderId(customerWalletAddress: String) - suspend fun clearAll(customerWalletAddress: String) + suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean + + suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) + + suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 0aa707e8cb..ef71a781c8 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -71,6 +71,7 @@ class ApiConfigTest { ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk()) ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk()) ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk()) + ApiConfig.ID.MoonPay -> MoonPay() } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index de6ac569e4..08e894665d 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -109,6 +109,7 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider) ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = appVersionProvider) ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage) + ApiConfig.ID.MoonPay -> MoonPay() } } } @@ -121,6 +122,7 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.StakeKit -> createStakeKitModel() ApiConfig.ID.TangemPay -> createTangemPayModel() ApiConfig.ID.BlockAid -> createBlockAidSdkModel() + ApiConfig.ID.MoonPay -> createMoonPayModel() } } @@ -263,6 +265,16 @@ internal class ProdApiConfigsManagerTest { ) } + private fun createMoonPayModel(): TestModel { + return TestModel( + id = ApiConfig.ID.MoonPay, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.moonpay.com/", + ), + ) + } + private fun String.checkHeaderValueOrEmpty(): String { for (i in this.indices) { val c = this[i] diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 558a2135a6..a84c13660c 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -194,6 +194,7 @@ Velocidad y tarifa Finalizar Sincronizar direcciones + Comenzar Ir al proveedor Ir al token Entendido @@ -984,6 +985,7 @@ Una cuenta de staking es una cuenta especial donde se almacenan los tokens SOL de staking. Se crea cuando delegas sus tokens a un validador para participar en la validación de transacciones y ganar recompensas. Se cobra una pequeña tarifa por crear la cuenta de staking, que se devuelve una vez que se completa el staking. Porcentaje de rendimiento anual El porcentaje de rendimiento anual que podrá ganar como participante en el staking. + APY muestra el porcentaje de rendimiento anual del validador en función de su rendimiento APR APY Las recompensas se acumulan automáticamente en su saldo de staking diariamente. @@ -1160,6 +1162,7 @@ El envío de fondos estará disponible una vez que se complete(n) la(s) transacción(es) pendiente(s) en la red %s Vender %s no es compatible con los proveedores actuales, pero estamos trabajando para agregar más opciones. El staking de %s no es compatible con los proveedores actuales, pero estamos trabajando para agregar más opciones. + Se ha revocado la aprobación. Sus fondos permanecen en modo de rendimiento. Para realizar acciones, vuelva al modo de rendimiento y otorgue el permiso nuevamente. Generar XPUB Ocultar Está a punto de ocultar este token de la pantalla principal. Puede volver a agregarlo en cualquier momento a través de la página de gestión de tokens. @@ -1549,6 +1552,7 @@ Al desactivar esto, retirará sus fondos de Aave a %s en su billetera y dejará de ganar recompensas. La comisión de red se deducirá del importe que retire. Desactivar el modo de rendimiento + Suministrar Suministro APY APY Los intereses se devengan automáticamente @@ -1560,5 +1564,6 @@ Deposite algo de %1$s %2$s para cubrir la tarifa de red para las transacciones No se puede cubrir la tarifa %s El servicio de intereses no está disponible en este momento. Vuelva a intentarlo más tarde. - Ganancias no disponibles + Modo de rendimiento no disponible + No se puede cargar el gráfico... diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 778dc97f1b..cb8fba86f1 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -172,6 +172,7 @@ Vitesse et frais Terminer Synchroniser les adresses + Commencer Aller au fournisseur Aller au jeton Compris @@ -949,6 +950,7 @@ Cela supprimera le portefeuille de l\'application. Le portefeuille lui-même peut être ajouté à nouveau. Nom Faites travailler votre jeton pour vous + Pour commencer à staker, votre compte TON doit être activé avec une transaction de 1 TON. Les fonds restent sur votre compte, car cette étape sert uniquement à l\'activer pour le staking. Le montant à staker doit être au moins %s Le montant du staking sera arrondi à %1$s TRX en raison des règles du réseau. Le montant d\'annulation du staking sera arrondi à %1$s TRX en raison des règles du réseau. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 7e27e29ccb..bcbee9cd62 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1318,6 +1318,7 @@ Переименование кошелька Разблокировать все Разблокировать все с %s + Активация аккаунта ПИН не принят. Попробуйте ещё раз или введите другой код. Что выбрать? Блокчейн недоступен. Попробуйте позже. diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index bb34e10eab..25532a4207 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -172,6 +172,7 @@ Швидкість та комісія Завершити Синхронізувати адреси + Почати Перейти до провайдера Перейти до токену Зrozуміло diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 759d5737b1..2dcc96ef01 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -969,6 +969,7 @@ %s min Available from + Available up to You get Service is provided by an external provider. \nTangem is not responsible. You can close this screen and check the transaction status on the token details screen. @@ -1370,6 +1371,8 @@ Pending The bank rejected this transaction request. This fee goes to cover the cost of handling your transfer. + Keep using your money. You can freeze anytime. + Unfreeze your card? Withdrawal Add funds Top-up options @@ -1394,6 +1397,7 @@ Reveal Swap any asset in your portfolio for card Card details + Unfreeze Card Create account and issue a card Tangem Pay View Status 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/buttons/actions/Actions.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/actions/Actions.kt index 8a639e82bf..ef86d84493 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/actions/Actions.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/actions/Actions.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -133,6 +135,13 @@ fun ActionBaseButton( } } .clip(shape) + .semantics { + contentDescription = if (config.shouldDimContent) { + "Action button is dimmed" + } else { + "Action button is not dimmed" + } + } .combinedClickable( enabled = config.isEnabled, onClick = config.onClick, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownItem.kt index dff51e77e2..1536d84244 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownItem.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.PopUpMenuTestTags @@ -28,7 +29,7 @@ fun TangemDropdownItem(item: TangemDropdownMenuItem, dismissParent: () -> Unit, .padding(vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing16) .testTag(PopUpMenuTestTags.BUTTON), text = item.title.resolveReference(), - style = TangemTheme.typography.button.copy(color = item.textColorProvider()), + style = TangemTheme.typography.button.copy(color = item.textColor.resolveReference()), ) } @@ -42,7 +43,7 @@ private fun Preview_TokenDetailsAppBarDropdownItem() { dismissParent = {}, item = TangemDropdownMenuItem( title = TextReference.Res(id = R.string.token_details_hide_token), - textColorProvider = { TangemTheme.colors.text.warning }, + textColor = themedColor { TangemTheme.colors.text.warning }, onClick = { }, ), ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenuItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenuItem.kt index 7d239636dd..189b35eb0e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenuItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenuItem.kt @@ -1,11 +1,10 @@ package com.tangem.core.ui.components.dropdownmenu -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.extensions.ColorReference import com.tangem.core.ui.extensions.TextReference data class TangemDropdownMenuItem( val title: TextReference, - val textColorProvider: @Composable () -> Color, + val textColor: ColorReference, val onClick: () -> Unit, ) \ 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/InputRowRecipient.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt index 323502fc97..9efba640db 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt @@ -140,7 +140,9 @@ fun InputRowRecipient( onClick = onPasteClick, backgroundColorEnabled = TangemTheme.colors.button.secondary, textColor = TangemTheme.colors.text.primary1, - modifier = Modifier.padding(start = TangemTheme.dimens.spacing8), + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing8) + .testTag(SendAddressScreenTestTags.ADDRESS_PASTE_BUTTON), ) } } @@ -224,6 +226,7 @@ private fun ResolvedAddressRow(isLoading: Boolean, resolvedAddress: String?) { text = state.address, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SendAddressScreenTestTags.RESOLVED_ADDRESS), ) } } 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/java/com/tangem/core/ui/test/FeeSelectorBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/FeeSelectorBlockTestTags.kt new file mode 100644 index 0000000000..1063a68aa8 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/FeeSelectorBlockTestTags.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.test + +object FeeSelectorBlockTestTags { + const val SELECTOR_BLOCK = "FEE_SELECTOR_BLOCK" + const val ICON = "FEE_SELECTOR_ICON" + const val TITLE = "FEE_SELECTOR_TITLE" + const val FEE_AMOUNT = "FEE_SELECTOR_FEE_AMOUNT" + const val TOOLTIP_ICON = "FEE_SELECTOR_TOOLTIP_ICON" + const val SELECT_FEE_ICON = "FEE_SELECTOR_SELECT_FEE_ICON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt index ebe05bc895..99c7f1e5c8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendAddressScreenTestTags.kt @@ -1,6 +1,17 @@ package com.tangem.core.ui.test object SendAddressScreenTestTags { - const val ADDRESS_TEXT_FIELD_TITLE = "SEND_ADDRESS_TEXT_FIELD_TITLE" - const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_TEXT_FIELD" + const val ADDRESS_TEXT_FIELD_TITLE = "SEND_ADDRESS_SCREEN_TEXT_FIELD_TITLE" + const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_SCREEN_TEXT_FIELD" + const val ADDRESS_PASTE_BUTTON = "SEND_ADDRESS_SCREEN_ADDRESS_PASTE_BUTTON" + const val RESOLVED_ADDRESS = "SEND_ADDRESS_SCREEN_RESOLVED_ADDRESS" + + const val RECENT_ADDRESS_TITLE = "SEND_ADDRESS_SCREEN_RECENT_ADDRESS_TITLE" + const val RECENT_ADDRESS_TEXT = "SEND_ADDRESS_SCREEN_RECENT_ADDRESS_TEXT" + const val RECENT_ADDRESS_ICON = "SEND_ADDRESS_SCREEN_RECENT_ADDRESS_ICON" + + const val DESTINATION_TAG_TEXT_FIELD_TITLE = "SEND_ADDRESS_SCREEN_DESTINATION_TAG_TEXT_FIELD_TITLE" + const val DESTINATION_TAG_TEXT_FIELD = "SEND_ADDRESS_SCREEN_DESTINATION_TAG_TEXT_FIELD" + const val DESTINATION_TAG_PASTE_BUTTON = "SEND_ADDRESS_SCREEN_DESTINATION_TAG_PASTE_BUTTON" + const val DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON = "SEND_ADDRESS_SCREEN_DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendConfirmScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendConfirmScreenTestTags.kt index 2e3212c540..f28a0cae01 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SendConfirmScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendConfirmScreenTestTags.kt @@ -2,4 +2,7 @@ package com.tangem.core.ui.test object SendConfirmScreenTestTags { const val SENDING_TEXT = "SEND_CONFIRM_SCREEN_SENDING_TEXT" + const val RECIPIENT_ADDRESS = "SEND_CONFIRM_SCREEN_RECIPIENT_ADDRESS" + const val BLOCKCHAIN_ADDRESS = "SEND_CONFIRM_SCREEN_BLOCKCHAIN_ADDRESS" + const val RECIPIENT_ADDRESS_ICON = "SEND_CONFIRM_SCREEN_RECIPIENT_ADDRESS_ICON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt index fe8785cc8c..49103a185d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt @@ -5,6 +5,8 @@ object SendScreenTestTags { const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE" const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD" + const val EQUIVALENT_INPUT_AMOUNT = "SEND_SCREEN_EQUIVALENT_INPUT_AMOUNT" + const val EXCHANGE_ICON = "SEND_SCREEN_EXCHANGE_ICON" const val TOKEN_NAME = "SEND_SCREEN_TOKEN_NAME" const val PRIMARY_AMOUNT = "SEND_SCREEN_PRIMARY_AMOUNT" const val SECONDARY_AMOUNT = "SEND_SCREEN_SECONDARY_AMOUNT" 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/drawable/ic_explore_20.xml b/core/ui/src/main/res/drawable/ic_explore_20.xml new file mode 100644 index 0000000000..f125f461b3 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_explore_20.xml @@ -0,0 +1,12 @@ + + + + + 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..93820578c2 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -15,9 +15,14 @@ tasks.withType().configureEach { dependencies { + // region Project - Common + implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value + // endregion + // region Project - Core implementation(projects.core.datasource) implementation(projects.core.configToggles) + implementation(projects.core.res) api(projects.core.utils) // endregion @@ -27,6 +32,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/AccountConverterFactoryContainer.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt index e8bcef8cce..8a7f2de408 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt @@ -7,21 +7,28 @@ import javax.inject.Inject /** * Container for converter factories related to accounts. * - * @property accountsListCF factory for creating an account list converter * @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter + * @property accountsListCF factory for creating an account list converter * @property cryptoPortfolioCF factory for creating a crypto portfolio converter + * @property userWalletsStore store for accessing user wallet data * * @constructor Creates an instance of the container with injected factories. * [REDACTED_AUTHOR] */ internal class AccountConverterFactoryContainer @Inject constructor( - val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory, + private val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory, private val accountsListCF: AccountListConverter.Factory, private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, private val userWalletsStore: UserWalletsStore, ) { + fun createWalletAccountsResponseConverter(userWalletId: UserWalletId): GetWalletAccountsResponseConverter { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + return getWalletAccountsResponseCF.create(userWallet) + } + fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter { val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt index b2744a7452..6a3bf7a736 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt @@ -29,7 +29,7 @@ internal class AccountListConverter @AssistedInject constructor( override fun convert(value: GetWalletAccountsResponse): AccountList { return AccountList( userWalletId = userWallet.walletId, - accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(), + accounts = value.accounts.map(cryptoPortfolioConverter::convert), totalAccounts = value.wallet.totalAccounts, sortType = TokensSortTypeConverter.convert(value.wallet.sort), groupType = TokensGroupTypeConverter.convert(value.wallet.group), 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..e85b397865 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,5 +1,6 @@ package com.tangem.data.account.di +import android.content.Context import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles @@ -10,9 +11,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 @@ -21,6 +22,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -42,8 +44,8 @@ internal object AccountDataModule { accountsResponseStoreFactory: AccountsResponseStoreFactory, userWalletsStore: UserWalletsStore, userTokensSaver: UserTokensSaver, - eTagsStore: ETagsStore, accountConverterFactoryContainer: AccountConverterFactoryContainer, + @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, ): AccountsCRUDRepository { return DefaultAccountsCRUDRepository( @@ -53,8 +55,9 @@ internal object AccountDataModule { archivedAccountsStoreFactory = ArchivedAccountsStoreFactory, userWalletsStore = userWalletsStore, userTokensSaver = userTokensSaver, - eTagsStore = eTagsStore, + archivedAccountsETagStore = RuntimeStateStore(emptyMap()), convertersContainer = accountConverterFactoryContainer, + resources = context.resources, 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..8b51092c49 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,35 +1,50 @@ 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.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.OnlyWalletIdBody 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 com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton /** * Handles errors that occur during the fetching of wallet accounts * + * @property tangemTechApi API for network requests * @property userTokensSaver saves user tokens to the storage * @property userTokensResponseStore provides access to user token responses. * @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse] + * @property eTagsStore store for ETags to manage caching + * @property dispatchers dispatchers * * @see DefaultWalletAccountsFetcher * [REDACTED_AUTHOR] */ +@Singleton internal class FetchWalletAccountsErrorHandler @Inject constructor( + private val tangemTechApi: TangemTechApi, private val userTokensSaver: UserTokensSaver, private val userTokensResponseStore: UserTokensResponseStore, private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, + private val eTagsStore: ETagsStore, + private val dispatchers: CoroutineDispatcherProvider, ) { /** @@ -49,23 +64,27 @@ 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) + val response = savedAccountsResponse ?: createDefaultResponse(userWalletId) val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse() val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) if (isNotFoundError) { - userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) - val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs) + val eTag = createWallet(userWalletId) - if (updatedResponse != null) { - response = updatedResponse + if (eTag != null) { + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag) + + pushWalletAccounts(userWalletId, accountDTOs) + userTokensSaver.push(userWalletId, userTokensResponse) } } @@ -90,4 +109,24 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( } .also { userTokensResponseStore.clear(userWalletId) } } + + /** + * Creates a wallet on the server and returns the ETag if successful. + * + * @param userWalletId The ID of the user wallet to create. + + */ + private suspend fun createWallet(userWalletId: UserWalletId): String? { + val creationResponse = withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = OnlyWalletIdBody(walletId = userWalletId.stringValue), + ) + } + + return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) { + creationResponse.headers[ETAG_HEADER]?.firstOrNull() + } else { + null + } + } } \ No newline at end of file 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..b8877392d3 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 @@ -1,21 +1,27 @@ package com.tangem.data.account.repository +import android.content.res.Resources import arrow.core.Option import arrow.core.raise.option import arrow.core.toOption +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.core.res.getStringSafe import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.converter.ArchivedAccountConverter 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 @@ -23,6 +29,7 @@ import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -43,8 +50,9 @@ 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 resources: Resources, private val dispatchers: CoroutineDispatcherProvider, ) : AccountsCRUDRepository { @@ -90,19 +98,49 @@ 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 saveAccountsLocally(accountList: AccountList) { + val converter = convertersContainer.createWalletAccountsResponseConverter( + userWalletId = accountList.userWalletId, + ) + + walletAccountsSaver.store( + userWalletId = accountList.userWalletId, + response = converter.convert(accountList), + ) } override suspend fun saveAccounts(accountList: AccountList) { @@ -154,9 +192,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 +213,25 @@ 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) + override fun checkDefaultAccountName(accountList: AccountList, accountName: AccountName) { + val hasDefaultName = accountList.accounts.any { it.accountName is AccountName.DefaultMain } + + if (!hasDefaultName) return + + val defaultName = resources.getStringSafe(AccountNameUM.DefaultMain.stringResId) + .let(AccountName::invoke).getOrNull() + + require(defaultName != accountName) { + "Cannot use default account name \"$accountName\" for custom accounts" + } + } + + 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..87a090099e 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.datasource.local.userwallet.UserWalletsStore 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 @@ -16,22 +18,22 @@ import javax.inject.Inject /** * Factory to create default [GetWalletAccountsResponse]. * - * @property userWalletsListRepository repository to get user wallet information + * @property userWalletsStore store 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] */ internal class DefaultWalletAccountsResponseFactory @Inject constructor( - private val userWalletsListRepository: UserWalletsListRepository, + private val userWalletsStore: UserWalletsStore, 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 { - val userWallet = userWalletsListRepository.userWalletsSync().firstOrNull { it.walletId == userWalletId } + fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse { + val userWallet = userWalletsStore.getSyncOrNull(userWalletId) val accountDTOs = userWallet?.let(::createDefaultAccountDTOs).orEmpty() val response = userTokensResponse.orDefault(userWallet = userWallet) @@ -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/converter/AccountConverterExt.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt index 12ac6dda89..a62a9840fb 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt @@ -77,7 +77,7 @@ internal fun createAccountList( ): AccountList { return AccountList( userWalletId = userWalletId, - accounts = setOf(createCryptoPortfolio(userWalletId)), + accounts = listOf(createCryptoPortfolio(userWalletId)), totalAccounts = 1, sortType = sortType, groupType = groupType, diff --git a/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt index 35e95170e1..0cc9314151 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt @@ -2,8 +2,6 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWalletId import org.junit.jupiter.api.Test @@ -16,13 +14,7 @@ class SaveWalletAccountsResponseConverterTest { fun convert() { // Arrange val userWalletId = UserWalletId("011") - - val accountList = AccountList( - userWalletId = userWalletId, - accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), - totalAccounts = 1, - ) - .getOrNull()!! + val accountList = createAccountList(userWalletId) // Act val actual = SaveWalletAccountsResponseConverter.convert(value = accountList) 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..dae6705e1c 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,15 +3,21 @@ 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.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.ETAG_HEADER +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.OnlyWalletIdBody 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 com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -27,14 +33,19 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class FetchWalletAccountsErrorHandlerTest { + private val tangemTechApi: TangemTechApi = mockk() private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() + private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true) private val handler = FetchWalletAccountsErrorHandler( + tangemTechApi = tangemTechApi, userTokensSaver = userTokensSaver, userTokensResponseStore = userTokensResponseStore, defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, + eTagsStore = eTagsStore, + dispatchers = TestingCoroutineDispatcherProvider(), ) private val pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse = @@ -53,6 +64,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 +76,7 @@ class FetchWalletAccountsErrorHandlerTest { handler.handle( error = error, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = response, pushWalletAccounts = pushWalletAccounts, storeWalletAccounts = storeWalletAccounts, ) @@ -91,6 +104,16 @@ class FetchWalletAccountsErrorHandlerTest { val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId) + val eTagValue = "etag-value" + val apiResponse = ApiResponse.Success( + data = Unit, + headers = mapOf(ETAG_HEADER to listOf(eTagValue)), + code = Code.CREATED, + ) + + coEvery { + tangemTechApi.createWallet(OnlyWalletIdBody(userWalletId.stringValue)) + } returns apiResponse coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse // Act @@ -105,6 +128,8 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + tangemTechApi.createWallet(OnlyWalletIdBody(userWalletId.stringValue)) + eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue) pushWalletAccounts(userWalletId, listOf(accountDTO)) storeWalletAccounts(userWalletId, savedAccountsResponse) } 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..34d8946b96 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 @@ -1,5 +1,6 @@ package com.tangem.data.account.repository +import android.content.res.Resources import arrow.core.None import arrow.core.toOption import com.google.common.truth.Truth @@ -10,7 +11,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,12 +54,14 @@ 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() private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk() + private val resources: Resources = mockk() + private val repository = DefaultAccountsCRUDRepository( tangemTechApi = tangemTechApi, walletAccountsSaver = walletAccountsSaver, @@ -67,8 +69,9 @@ class DefaultAccountsCRUDRepositoryTest { archivedAccountsStoreFactory = archivedAccountsStoreFactory, userWalletsStore = userWalletsStore, userTokensSaver = userTokensSaver, - eTagsStore = eTagsStore, + archivedAccountsETagStore = archivedAccountsETagStore, convertersContainer = convertersContainer, + resources = resources, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -532,7 +535,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 +549,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 +562,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 +574,7 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull() coVerifyOrder { - eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) + archivedAccountsETagStore.getSyncOrNull() tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } } @@ -644,15 +648,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 +668,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 +695,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 +716,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..774de05498 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,12 +3,12 @@ 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.datasource.local.userwallet.UserWalletsStore 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.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet @@ -23,17 +23,17 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultWalletAccountsResponseFactoryTest { - private val userWalletsListRepository = mockk() + private val userWalletsStore = mockk() 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, + userWalletsStore = userWalletsStore, cryptoPortfolioCF = cryptoPortfolioCF, userTokensResponseFactory = userTokensResponseFactory, - cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + networkFactory = networkFactory, ) private val userWalletId = UserWalletId("011") @@ -46,11 +46,11 @@ class DefaultWalletAccountsResponseFactoryTest { @AfterEach fun tearDownEach() { clearMocks( - userWalletsListRepository, + userWalletsStore, cryptoPortfolioCF, cryptoPortfolioConverter, userTokensResponseFactory, - cardCryptoCurrencyFactory, + networkFactory, ) } @@ -63,12 +63,12 @@ class DefaultWalletAccountsResponseFactoryTest { tokens = emptyList(), ) - coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns null every { - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, + userTokensResponseFactory.createDefaultResponse( + userWallet = null, + networkFactory = networkFactory, + accountId = null, ) } returns userTokensResponse @@ -88,11 +88,11 @@ class DefaultWalletAccountsResponseFactoryTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - userWalletsListRepository.userWalletsSync() - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, + userWalletsStore.getSyncOrNull(userWalletId) + userTokensResponseFactory.createDefaultResponse( + userWallet = null, + networkFactory = networkFactory, + accountId = null, ) } } @@ -104,27 +104,24 @@ class DefaultWalletAccountsResponseFactoryTest { every { walletId } returns userWalletId } - val defaultCoins = listOf(mockk()) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) - every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns defaultCoins + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet 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) @@ -145,13 +142,12 @@ class DefaultWalletAccountsResponseFactoryTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - userWalletsListRepository.userWalletsSync() + userWalletsStore.getSyncOrNull(userWalletId) 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 } - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) - every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + 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 @@ -203,7 +203,7 @@ class DefaultWalletAccountsResponseFactoryTest { every { walletId } returns userWalletId } val assignedTokens = listOf(mockk(), mockk()) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet val userTokensResponse = UserTokensResponse( group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, 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 2c592777f5..66faea0a74 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.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -18,26 +19,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}") @@ -52,6 +58,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor( blockchain = blockchain, extraDerivationPath = responseToken.derivationPath, userWallet = userWallet, + accountIndex = accountIndex, ) ?: return null return createCurrency(responseToken = responseToken, userWallet = userWallet, network = network) 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 c7e03876f3..804fb0db6d 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, @@ -98,6 +107,7 @@ class NetworkFactory @Inject constructor( extraDerivationPath: String?, derivationStyleProvider: DerivationStyleProvider?, canHandleTokens: Boolean, + accountIndex: DerivationIndex? = null, ): Network? { return create( blockchain = blockchain, @@ -105,6 +115,7 @@ class NetworkFactory @Inject constructor( blockchain = blockchain, extraDerivationPath = extraDerivationPath, cardDerivationStyleProvider = derivationStyleProvider, + accountIndex = accountIndex, ), canHandleTokens = canHandleTokens, ) @@ -148,14 +159,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()) { @@ -164,10 +176,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) } } } @@ -175,8 +188,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 { @@ -344,8 +368,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/DefaultOnrampTransactionRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampTransactionRepository.kt index 49774d1443..5e515100e3 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampTransactionRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampTransactionRepository.kt @@ -44,6 +44,12 @@ internal class DefaultOnrampTransactionRepository( } } + override fun getAllTransactions(): Flow> { + return appPreferencesStore + .getObjectSet(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY) + .map { transactions -> transactions.map(transactionConverter::convert) } + } + override suspend fun getTransactionById(txId: String): OnrampTransaction? = withContext(dispatchers.io) { val stored = appPreferencesStore.getObjectSetSync( PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY, 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/swap/build.gradle.kts b/data/swap/build.gradle.kts index f2ff37fff6..78b5b851c7 100644 --- a/data/swap/build.gradle.kts +++ b/data/swap/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(projects.domain.networks) implementation(projects.domain.staking.models) implementation(projects.domain.staking) + implementation(projects.domain.account) /** Tangem SDK */ implementation(tangemDeps.blockchain) { diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt index f7ade3cac9..0e2d2a1a79 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt @@ -15,6 +15,9 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.supplier.MultiAccountListSupplier +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.wallet.UserWallet @@ -27,6 +30,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn internal class DefaultSwapTransactionRepository( @@ -34,6 +38,8 @@ internal class DefaultSwapTransactionRepository( private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, private val networkFactory: NetworkFactory, private val dispatchers: CoroutineDispatcherProvider, + private val multiAccountListSupplier: MultiAccountListSupplier, + private val accountsFeatureToggles: AccountsFeatureToggles, ) : SwapTransactionRepository { private val listConverter by lazy(LazyThreadSafetyMode.NONE) { @@ -54,13 +60,15 @@ internal class DefaultSwapTransactionRepository( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transaction: SwapTransactionModel, ) { transaction.status?.let { storeTransactionState( txId = transaction.txId, status = it, - accountWithCurrency = null, + accountWithCurrency = fromAccount?.accountId to fromCryptoCurrency, ) } appPreferencesStore.editData { mutablePreferences -> @@ -87,12 +95,16 @@ internal class DefaultSwapTransactionRepository( userWalletId = userWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = fromAccount, + toAccount = toAccount, transactions = tokenTransactions, ) ?: listOf( listConverter.default( userWalletId = userWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = fromAccount, + toAccount = toAccount, tokenTransactions = listOf(converter.convert(transaction)), ), ), @@ -110,7 +122,12 @@ internal class DefaultSwapTransactionRepository( flow2 = appPreferencesStore.getObjectMap( key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, ), - ) { savedTransactions, txStatuses -> + flow3 = if (accountsFeatureToggles.isFeatureEnabled) { + multiAccountListSupplier() + } else { + flowOf(emptyList()) + }, + ) { savedTransactions, txStatuses, multiAccountList -> val currencyTxs = savedTransactions ?.filter { it.userWalletId == userWallet.walletId.stringValue && @@ -123,6 +140,7 @@ internal class DefaultSwapTransactionRepository( currencyTxs?.mapNotNull { listConverter.convertBack( value = it, + multiAccountList = multiAccountList, userWallet = userWallet, txStatuses = txStatuses, ) @@ -221,10 +239,13 @@ internal class DefaultSwapTransactionRepository( fromCryptoCurrencyId == fromCurrencyId.value } + @Suppress("LongParameterList") private fun List.updateList( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transactions: List, ): List { return addOrReplace( @@ -232,6 +253,8 @@ internal class DefaultSwapTransactionRepository( userWalletId = userWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = fromAccount, + toAccount = toAccount, tokenTransactions = transactions, ), predicate = { diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt index 16b144b862..9688b3086b 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -9,6 +9,9 @@ import com.tangem.data.swap.models.SwapStatusDTO import com.tangem.data.swap.models.SwapTransactionDTO import com.tangem.data.swap.models.SwapTransactionListDTO import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +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 @@ -32,11 +35,11 @@ internal class SavedSwapTransactionListConverter( toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( currency = value.fromCryptoCurrency, - accountId = null, + accountId = value.fromAccount?.accountId, ), toTokensResponse = userTokensResponseFactory.createResponseToken( currency = value.toCryptoCurrency, - accountId = null, + accountId = value.toAccount?.accountId, ), transactions = savedSwapTransactionConverter.convertList(value.transactions), ) @@ -45,9 +48,12 @@ internal class SavedSwapTransactionListConverter( value: SwapTransactionListDTO, userWallet: UserWallet, txStatuses: Map, + multiAccountList: List, ): SwapTransactionListModel? { val fromToken = value.fromTokensResponse val toToken = value.toTokensResponse + val fromDerivationIndex = fromToken?.getDerivationIndex() + val toDerivationIndex = toToken?.getDerivationIndex() return if (fromToken == null || toToken == null) { null } else { @@ -73,14 +79,25 @@ internal class SavedSwapTransactionListConverter( toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = getAccountByDerivationIndex( + multiAccountList = multiAccountList, + derivationIndex = fromDerivationIndex, + ), + toAccount = getAccountByDerivationIndex( + multiAccountList = multiAccountList, + derivationIndex = toDerivationIndex, + ), ) } } + @Suppress("LongParameterList") fun default( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, tokenTransactions: List, ) = SwapTransactionListDTO( userWalletId = userWalletId.stringValue, @@ -88,9 +105,12 @@ internal class SavedSwapTransactionListConverter( toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( currency = fromCryptoCurrency, - accountId = null, + accountId = fromAccount?.accountId, + ), + toTokensResponse = userTokensResponseFactory.createResponseToken( + currency = toCryptoCurrency, + accountId = toAccount?.accountId, ), - toTokensResponse = userTokensResponseFactory.createResponseToken(currency = toCryptoCurrency, accountId = null), transactions = tokenTransactions, ) @@ -108,7 +128,24 @@ internal class SavedSwapTransactionListConverter( blockchain = blockchain, extraDerivationPath = token.derivationPath, userWallet = userWallet, + accountIndex = token.accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() }, ) } } + + private fun getAccountByDerivationIndex( + multiAccountList: List, + derivationIndex: DerivationIndex?, + ): Account.CryptoPortfolio? { + if (derivationIndex == null) return null + + return multiAccountList.asSequence().firstNotNullOfOrNull { accountList -> + accountList.accounts.asSequence().filterIsInstance() + .firstOrNull { it.derivationIndex == derivationIndex } + } + } + + private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { + return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() } + } } \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt b/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt index 7784c4ea1b..fd3fadb12b 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt @@ -12,6 +12,8 @@ import com.tangem.datasource.api.express.models.response.ExpressErrorResponse import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.express.ExpressRepository import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier @@ -68,12 +70,16 @@ internal object SwapDataModule { appPreferencesStore: AppPreferencesStore, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, networkFactory: NetworkFactory, + multiAccountListSupplier: MultiAccountListSupplier, + accountsFeatureToggles: AccountsFeatureToggles, dispatchers: CoroutineDispatcherProvider, ): SwapTransactionRepository { return DefaultSwapTransactionRepository( appPreferencesStore = appPreferencesStore, responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, networkFactory = networkFactory, + multiAccountListSupplier = multiAccountListSupplier, + accountsFeatureToggles = accountsFeatureToggles, dispatchers = dispatchers, ) } 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 8495e3e6fa..58bc11680b 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,20 +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.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.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 @@ -40,7 +34,7 @@ internal object TokensDataModule { walletManagersFacade: WalletManagersFacade, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, - expressServiceLoader: ExpressServiceLoader, + expressServiceFetcher: ExpressServiceFetcher, excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, tokensSaver: UserTokensSaver, @@ -52,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/build.gradle.kts b/data/visa/build.gradle.kts index 75c79ae815..0e59eaa831 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(projects.domain.tokens) implementation(projects.domain.networks) implementation(projects.domain.walletManager) + implementation(projects.domain.quotes) /** Feature API - remove after removing [HotWalletFeatureToggles] */ implementation(projects.features.hotWallet.api) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultDataForReceiveFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultDataForReceiveFactory.kt deleted file mode 100644 index 76a1ab3929..0000000000 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultDataForReceiveFactory.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.tangem.data.pay - -import arrow.core.Either -import com.squareup.moshi.Moshi -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.core.error.UniversalError -import com.tangem.data.common.currency.CryptoCurrencyFactory -import com.tangem.data.pay.util.TangemPayErrorConverter -import com.tangem.data.pay.util.TangemPayWalletsManager -import com.tangem.datasource.di.NetworkMoshi -import com.tangem.domain.models.ReceiveAddressModel -import com.tangem.domain.models.ReceiveAddressModel.NameService -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.pay.DataForReceive -import com.tangem.domain.pay.DataForReceiveFactory -import kotlinx.coroutines.CancellationException -import timber.log.Timber -import javax.inject.Inject - -private const val TAG = "TangemPay: TokenReceiveConfigFactory" - -internal class DefaultDataForReceiveFactory @Inject constructor( - @NetworkMoshi moshi: Moshi, - private val tangemPayWalletsManager: TangemPayWalletsManager, - excludedBlockchains: ExcludedBlockchains, -) : DataForReceiveFactory { - - private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) { - CryptoCurrencyFactory(excludedBlockchains) - } - private val errorConverter by lazy(mode = LazyThreadSafetyMode.NONE) { TangemPayErrorConverter(moshi) } - - override fun getDataForReceive(depositAddress: String, chainId: Int): Either { - return try { - val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking() - - /** - * Create [CryptoCurrency.Coin] only for F&F. - * Later will use [CryptoCurrency.Token] when contractAddresses will be provided by BFF. - */ - val currency = cryptoCurrencyFactory.createCoin( - chainId = chainId, - extraDerivationPath = null, - userWallet = wallet, - ) ?: error("Cannot create crypto currency from chainId $chainId") - - val result = DataForReceive( - currency = currency, - walletId = wallet.walletId, - receiveAddress = listOf(ReceiveAddressModel(nameService = NameService.Default, value = depositAddress)), - ) - - Either.Right(result) - } catch (exception: Exception) { - when (exception) { - is CancellationException -> { - throw exception - } - else -> { - Timber.tag(TAG).e(exception) - Either.Left(errorConverter.convert(exception)) - } - } - } - } -} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayTopUpDataFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayTopUpDataFactory.kt new file mode 100644 index 0000000000..fafac4cce8 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayTopUpDataFactory.kt @@ -0,0 +1,96 @@ +package com.tangem.data.pay + +import arrow.core.Either +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.ethereum.Chain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.core.error.UniversalError +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.pay.util.TangemPayErrorConverter +import com.tangem.data.pay.util.TangemPayWalletsManager +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.ReceiveAddressModel.NameService +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.TangemPayTopUpData +import com.tangem.domain.pay.TangemPayTopUpDataFactory +import kotlinx.coroutines.CancellationException +import timber.log.Timber +import java.math.BigDecimal +import javax.inject.Inject + +private const val TAG = "TangemPay: DefaultDataForTopUpFactory" +/** + * Custom token parameters. Will be used only for F&F. + */ +private const val TOKEN_ID = "usd-coin" +private const val TOKEN_NAME = "USDC" +private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" +private const val TOKEN_DECIMALS = 6 + +internal class DefaultTangemPayTopUpDataFactory @Inject constructor( + @NetworkMoshi moshi: Moshi, + private val tangemPayWalletsManager: TangemPayWalletsManager, + excludedBlockchains: ExcludedBlockchains, +) : TangemPayTopUpDataFactory { + + private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + CryptoCurrencyFactory(excludedBlockchains) + } + private val networkFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + NetworkFactory(excludedBlockchains) + } + private val errorConverter by lazy(mode = LazyThreadSafetyMode.NONE) { TangemPayErrorConverter(moshi) } + + private fun getCurrency(userWallet: UserWallet, chainId: Int): CryptoCurrency { + val chain = requireNotNull(Chain.entries.find { it.id == chainId }) { "Can not find chain with $chainId" } + val blockchain = requireNotNull(chain.blockchain) + val network = networkFactory.create( + blockchain = blockchain, + extraDerivationPath = null, + userWallet = userWallet, + ) + return cryptoCurrencyFactory.createToken( + network = requireNotNull(network), + rawId = CryptoCurrency.RawID(TOKEN_ID), + name = TOKEN_NAME, + symbol = TOKEN_NAME, + contractAddress = TOKEN_CONTRACT_ADDRESS, + decimals = TOKEN_DECIMALS, + ) + } + + override fun create( + depositAddress: String, + chainId: Int, + cryptoBalance: BigDecimal, + fiatBalance: BigDecimal, + ): Either { + return try { + val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking() + val currency = getCurrency(wallet, chainId) + val result = TangemPayTopUpData( + currency = currency, + walletId = wallet.walletId, + cryptoBalance = cryptoBalance, + fiatBalance = fiatBalance, + depositAddress = depositAddress, + receiveAddress = listOf(ReceiveAddressModel(nameService = NameService.Default, value = depositAddress)), + ) + + Either.Right(result) + } catch (exception: Exception) { + when (exception) { + is CancellationException -> { + throw exception + } + else -> { + Timber.tag(TAG).e(exception) + Either.Left(errorConverter.convert(exception)) + } + } + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt index 450fc7c70d..bca25d2bb9 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -4,9 +4,8 @@ import arrow.core.Either import arrow.core.raise.either import com.tangem.common.CompletionResult import com.tangem.domain.pay.datasource.TangemPayAuthDataSource -import com.tangem.domain.visa.model.VisaDataForApprove -import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.sdk.api.TangemSdkManager import javax.inject.Inject @@ -16,29 +15,14 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( private val tangemSdkManager: TangemSdkManager, ) : TangemPayAuthDataSource { - override suspend fun generateNewAuthTokens(address: String, cardId: String): Either = - either { - val challenge = visaAuthRemoteDataSource - .getCustomerWalletAuthChallenge(address) - .mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") } - .bind() + override suspend fun produceInitialCredentials(cardId: String): Either { + val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId) - val signed = tangemSdkManager.visaCustomerWalletApprove( - VisaDataForApprove( - customerWalletCardId = cardId, - targetAddress = address, - dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge), - ), - ).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind() - - visaAuthRemoteDataSource.getTokenWithCustomerWallet( - sessionId = challenge.session.sessionId, - signature = signed.signature, - nonce = signed.dataToSign.hashToSign, - ) - .mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") } - .bind() + return when (initialCredentials) { + is CompletionResult.Failure<*> -> Either.Left(initialCredentials.error) + is CompletionResult.Success -> Either.Right(initialCredentials.data) } + } override suspend fun refreshAuthTokens(refreshToken: String): Either = either { visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens( @@ -47,9 +31,4 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( .mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") } .bind() } -} - -private fun CompletionResult.toEither(map: (Throwable) -> Throwable) = when (this) { - is CompletionResult.Success -> Either.Right(data) - is CompletionResult.Failure -> Either.Left(map(error)) } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index c6222c62c7..43a2e87cc9 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -1,16 +1,19 @@ package com.tangem.data.pay.di -import com.tangem.data.pay.DefaultDataForReceiveFactory +import com.tangem.data.pay.DefaultTangemPayTopUpDataFactory import com.tangem.data.pay.repository.DefaultCardDetailsRepository import com.tangem.data.pay.repository.DefaultKycRepository import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository import com.tangem.data.pay.repository.DefaultOnboardingRepository -import com.tangem.domain.pay.DataForReceiveFactory +import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase +import com.tangem.domain.pay.TangemPayTopUpDataFactory import com.tangem.domain.pay.repository.CardDetailsRepository import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase +import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository import dagger.Binds import dagger.Module @@ -41,7 +44,13 @@ internal interface TangemPayDataModule { @Binds @Singleton - fun bindDataForReceiveFactory(factory: DefaultDataForReceiveFactory): DataForReceiveFactory + fun bindDataForReceiveFactory(factory: DefaultTangemPayTopUpDataFactory): TangemPayTopUpDataFactory + + @Binds + @Singleton + fun bindGetTangemPayCurrencyStatusUseCase( + impl: DefaultGetTangemPayCurrencyStatusUseCase, + ): GetTangemPayCurrencyStatusUseCase companion object { @Provides @@ -52,6 +61,14 @@ internal interface TangemPayDataModule { return TangemPayMainScreenCustomerInfoUseCase(repository = repository) } + @Provides + @Singleton + fun provideProduceTangemPayInitialDataUseCase( + repository: OnboardingRepository, + ): ProduceTangemPayInitialDataUseCase { + return ProduceTangemPayInitialDataUseCase(repository = repository) + } + @Provides @Singleton fun provideTangemPayIssueOrderUseCase(repository: OnboardingRepository): TangemPayIssueOrderUseCase { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt new file mode 100644 index 0000000000..cdbe85d100 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt @@ -0,0 +1,6 @@ +package com.tangem.data.pay.entity + +internal data class EncryptedData( + val encryptedBase64: String, + val ivBase64: String, +) \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt index 2875b51a63..3478231e55 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt @@ -9,6 +9,9 @@ import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.CardDetailsRequest +import com.tangem.datasource.api.pay.models.request.SetPinRequest +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails import com.tangem.domain.pay.repository.CardDetailsRepository @@ -22,6 +25,7 @@ internal class DefaultCardDetailsRepository @Inject constructor( private val visaLibLoader: VisaLibLoader, private val apiConfigsManager: ApiConfigsManager, private val rainCryptoUtil: RainCryptoUtil, + private val storage: TangemPayStorage, ) : CardDetailsRepository { override suspend fun getCardBalance(): Either { @@ -39,7 +43,7 @@ internal class DefaultCardDetailsRepository @Inject constructor( override suspend fun revealCardDetails(): Either { return requestHelper.runWithErrorLogs(TAG) { val publicKeyBase64 = getPublicKeyBase64() - val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64) + val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64) val result = requestHelper.request { authHeader -> tangemPayApi.revealCardDetails( @@ -51,14 +55,15 @@ internal class DefaultCardDetailsRepository @Inject constructor( val pan = rainCryptoUtil.decryptSecret( base64Secret = result.pan.secret, base64Iv = result.pan.iv, - secretKeyHex = secretKeyHex, + secretKeyBytes = secretKeyBytes, ) val cvv = rainCryptoUtil.decryptSecret( base64Secret = result.cvv.secret, base64Iv = result.cvv.iv, - secretKeyHex = secretKeyHex, + secretKeyBytes = secretKeyBytes, ) + secretKeyBytes.fill(0) TangemPayCardDetails( pan = pan, @@ -69,6 +74,44 @@ internal class DefaultCardDetailsRepository @Inject constructor( } } + override suspend fun setPin(pin: String): Either { + return requestHelper.runWithErrorLogs(TAG) { + val publicKeyBase64 = getPublicKeyBase64() + val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64) + val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes) + secretKeyBytes.fill(0) + + val status = requestHelper.request { authHeader -> + tangemPayApi.setPin( + authHeader = authHeader, + body = SetPinRequest( + sessionId = sessionId, + pin = encryptedData.encryptedBase64, + iv = encryptedData.ivBase64, + ), + ) + }.result?.result ?: error("Cannot set pin code") + when (status) { + SetPinResult.SUCCESS.name -> SetPinResult.SUCCESS + SetPinResult.PIN_TOO_WEAK.name -> SetPinResult.PIN_TOO_WEAK + SetPinResult.DECRYPTION_ERROR.name -> SetPinResult.DECRYPTION_ERROR + else -> SetPinResult.UNKNOWN_ERROR + } + } + } + + override suspend fun isAddToWalletDone(): Either { + return requestHelper.runWithErrorLogs(TAG) { + storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress()) + } + } + + override suspend fun setAddToWalletAsDone(): Either { + return requestHelper.runWithErrorLogs(TAG) { + storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(), isDone = true) + } + } + private suspend fun getPublicKeyBase64(): String { val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment return when (env) { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index dbe40d9c20..2f5582be20 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -2,11 +2,14 @@ package com.tangem.data.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.data.pay.util.TangemPayWalletsManager +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest import com.tangem.datasource.api.pay.models.request.OrderRequest import com.tangem.datasource.api.pay.models.response.CustomerMeResponse import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.CustomerInfo.ProductInstance @@ -26,17 +29,44 @@ internal class DefaultOnboardingRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, private val tangemPayStorage: TangemPayStorage, + private val authDataSource: TangemPayAuthDataSource, + private val tangemPayWalletsManager: TangemPayWalletsManager, ) : OnboardingRepository { override suspend fun validateDeeplink(link: String): Either { return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.request { - tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)) - }.result + val result = tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)) + .getOrThrow() + .result result?.status == VALID_STATUS } } + override suspend fun isTangemPayInitialDataProduced(): Boolean { + val walletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId + val customerWalletAddress = tangemPayStorage.getCustomerWalletAddress(walletId) ?: return false + tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return false + + return true + } + + override suspend fun produceInitialData() { + val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay() + val initialCredentials = authDataSource.produceInitialCredentials(cardId = wallet.cardId) + .fold( + ifLeft = { error -> error("Can not produce initial data: ${error.message}") }, + ifRight = { it }, + ) + tangemPayStorage.storeCustomerWalletAddress( + userWalletId = wallet.walletId, + customerWalletAddress = initialCredentials.customerWalletAddress, + ) + tangemPayStorage.storeAuthTokens( + customerWalletAddress = initialCredentials.customerWalletAddress, + tokens = initialCredentials.authTokens, + ) + } + override suspend fun getCustomerInfo(): Either { return requestHelper.runWithErrorLogs(TAG) { val result = requestHelper.request { authHeader -> @@ -84,7 +114,7 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun createOrder(): Either = withContext(dispatcherProvider.io) { requestHelper.runWithErrorLogs(TAG) { val walletAddress = requestHelper.getCustomerWalletAddress() - val result = requestHelper.requestWithPersistedToken { authHeader -> + val result = requestHelper.request { authHeader -> tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress)) }.result ?: error("Create order result is null") @@ -128,7 +158,7 @@ internal class DefaultOnboardingRepository @Inject constructor( } private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo { - val result = requestHelper.requestWithPersistedToken { authHeader -> + val result = requestHelper.request { authHeader -> tangemPayApi.getCustomerMe(authHeader) }.result return getCustomerInfo(result) 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/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt index c52ec65d24..cd24329183 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt @@ -2,9 +2,7 @@ package com.tangem.data.pay.repository import arrow.core.Either import com.squareup.moshi.Moshi -import com.tangem.blockchain.common.Blockchain import com.tangem.core.error.UniversalError -import com.tangem.data.common.network.NetworkFactory import com.tangem.data.pay.util.TangemPayErrorConverter import com.tangem.data.pay.util.TangemPayWalletsManager import com.tangem.datasource.api.common.response.ApiResponse @@ -14,10 +12,10 @@ import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.visa.model.VisaAuthTokens -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.derivations.derivationStyleProvider +import com.tangem.domain.visa.model.getAuthHeader import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -30,11 +28,9 @@ internal class TangemPayRequestPerformer @Inject constructor( private val tangemPayStorage: TangemPayStorage, private val authDataSource: TangemPayAuthDataSource, private val tangemPayWalletsManager: TangemPayWalletsManager, - private val walletManagersFacade: WalletManagersFacade, - private val networkFactory: NetworkFactory, ) { - private var customerWalletAddress: String? = null + private val customerWalletAddress = MutableStateFlow(null) private val refreshTokensMutex = Mutex() private var refreshTokensJob: Deferred? = null @@ -67,21 +63,14 @@ internal class TangemPayRequestPerformer @Inject constructor( ) } - suspend fun requestWithPersistedToken(requestBlock: suspend (header: String) -> ApiResponse): T = - withContext(dispatchers.io) { - performRequest( - requestBlock = requestBlock, - getTokens = { getAccessTokensIfSaved() ?: error("Cannot get saved access tokens") }, - refreshTokens = ::refreshAuthTokens, - ) - } - private suspend fun performRequest( requestBlock: suspend (header: String) -> ApiResponse, getTokens: (suspend () -> VisaAuthTokens), refreshTokens: (suspend () -> VisaAuthTokens)? = null, ): T = runCatching { - requestBlock("Bearer ${getTokens().accessToken}").getOrThrow() + val tokens = getTokens() + val header = tokens.getAuthHeader() + requestBlock(header).getOrThrow() }.getOrElse { error -> val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) { @@ -116,52 +105,34 @@ internal class TangemPayRequestPerformer @Inject constructor( return result } - suspend fun getCustomerWalletAddress(): String = customerWalletAddress ?: fetchAuthInputData().address + suspend fun getCustomerWalletAddress(): String { + val existingAddress = customerWalletAddress.value + if (existingAddress != null) { + return existingAddress + } + val storedAddress = tangemPayStorage.getCustomerWalletAddress( + userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId, + ) ?: error("Can not find customer address") + + customerWalletAddress.value = storedAddress + return storedAddress + } private suspend fun getAccessTokens(): VisaAuthTokens { - return getAccessTokensIfSaved() ?: fetchTokens() - } - - private suspend fun getAccessTokensIfSaved(): VisaAuthTokens? { - return tangemPayStorage.getAuthTokens(getCustomerWalletAddress()) - } - - private suspend fun fetchAuthInputData(): AuthInputData { - val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay() - - val network = networkFactory.create( - blockchain = Blockchain.Polygon, - extraDerivationPath = null, - derivationStyleProvider = wallet.derivationStyleProvider, - canHandleTokens = true, - ) ?: error("Cannot create network") - - val address = walletManagersFacade.getDefaultAddress(wallet.walletId, network) - ?: error("Cannot get polygon address") - - customerWalletAddress = address - - return AuthInputData(address, wallet.cardId) - } - - private suspend fun fetchTokens(): VisaAuthTokens { - val inputData = fetchAuthInputData() - val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId) - .getOrNull() ?: error("Cannot fetch tokens") - tangemPayStorage.storeAuthTokens(inputData.address, tokens) + val walletAddress = getCustomerWalletAddress() + val tokens = tangemPayStorage.getAuthTokens(walletAddress) ?: error("Auth tokens are not stored") return tokens } private suspend fun refreshAuthTokens(): VisaAuthTokens { val customerWalletAddress = getCustomerWalletAddress() val refreshToken = getAccessTokens().refreshToken.value - val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull() ?: error("Cannot refresh tokens") + val tokens = authDataSource.refreshAuthTokens(refreshToken) + .fold( + ifLeft = { error -> error("Cannot refresh tokens: ${error.message}") }, + ifRight = { it }, + ) tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens) return tokens } -} - -internal data class AuthInputData( - val address: String, - val cardId: String, -) \ No newline at end of file +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultGetTangemPayCurrencyStatusUseCase.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultGetTangemPayCurrencyStatusUseCase.kt new file mode 100644 index 0000000000..53ca506151 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultGetTangemPayCurrencyStatusUseCase.kt @@ -0,0 +1,62 @@ +package com.tangem.data.pay.usecase + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase +import java.math.BigDecimal +import javax.inject.Inject + +internal class DefaultGetTangemPayCurrencyStatusUseCase @Inject constructor( + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, + private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher, +) : GetTangemPayCurrencyStatusUseCase { + + override suspend fun invoke( + currency: CryptoCurrency, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + depositAddress: String, + ): CryptoCurrencyStatus? { + val rawCurrencyId = currency.id.rawCurrencyId ?: return null + val quoteStatus = singleQuoteStatusSupplier.getSyncOrNull( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId), + )?.value + val quoteStatusData: QuoteStatus.Data = when (quoteStatus) { + is QuoteStatus.Data -> quoteStatus + else -> { + singleQuoteStatusFetcher.invoke( + params = SingleQuoteStatusFetcher.Params(rawCurrencyId = rawCurrencyId, appCurrencyId = null), + ) + singleQuoteStatusSupplier.getSyncOrNull( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId), + )?.value as? QuoteStatus.Data + } + } ?: return null + + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoAmount, + fiatRate = quoteStatusData.fiatRate, + priceChange = quoteStatusData.priceChange, + fiatAmount = fiatAmount, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + yieldBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt index 24b60d6ea8..4fa2681263 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt @@ -1,11 +1,10 @@ package com.tangem.data.pay.util import android.util.Base64 -import com.tangem.utils.extensions.hexToBytes import java.nio.charset.StandardCharsets import java.security.KeyFactory import java.security.SecureRandom -import com.tangem.common.extensions.toHexString +import com.tangem.data.pay.entity.EncryptedData import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import java.security.spec.X509EncodedKeySpec @@ -21,26 +20,38 @@ private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding" private const val AES_TRANSFORMATION = "AES/GCM/NoPadding" private const val AES_ALGORITHM = "AES" private const val RSA_ALGORITHM = "RSA" +private const val IV_LENGTH_BYTES = 16 +private const val PIN_LENGTH = 4 +private const val PIN_LENGTH_BYTES = 8 +private const val PIN_BLOCK_ISO_9564_FORMAT_PREFIX = '2' +private const val PIN_BLOCK_FILL_CHAR = 'F' internal class RainCryptoUtil @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, ) { - suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair = + suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair = withContext(dispatchers.default) { - val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString() - val sessionId = generateSessionId(publicKeyBase64, secretKeyHex) - secretKeyHex to sessionId + val secretKeyBytes = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) } + val sessionId = generateSessionId(publicKeyBase64, secretKeyBytes) + secretKeyBytes to sessionId } - suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String = + suspend fun encryptPin(pin: String, secretKeyBytes: ByteArray): EncryptedData = withContext(dispatchers.default) { + val bytes = pinBlockByteArray(pin) + try { + encryptSecret(bytes, secretKeyBytes) + } finally { + bytes.clear() + } + } + + suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyBytes: ByteArray): String = withContext(dispatchers.default) { val cipherTextBytes = Base64.decode(base64Secret, Base64.NO_WRAP) if (cipherTextBytes.size < TAG_LENGTH_BYTES) error("Cipher text too short") val initializationVectorBytes = Base64.decode(base64Iv, Base64.NO_WRAP) - val secretKeyBytes = secretKeyHex.hexToBytes() - val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM) val gcmParameterSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, initializationVectorBytes) @@ -51,8 +62,25 @@ internal class RainCryptoUtil @Inject constructor( plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") } } - private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String { - val secretKeyBytes = secretKeyHex.hexToBytes() + private fun encryptSecret(bytes: ByteArray, secretKeyBytes: ByteArray): EncryptedData { + val iv = ByteArray(IV_LENGTH_BYTES).also { SecureRandom().nextBytes(it) } + + val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM) + val gcmSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, iv) + + val cipher = Cipher.getInstance(AES_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, aesSecretKey, gcmSpec) + + val ciphertext = cipher.doFinal(bytes) + bytes.clear() + + return EncryptedData( + encryptedBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP), + ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP), + ) + } + + private fun generateSessionId(publicKeyBase64: String, secretKeyBytes: ByteArray): String { val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP) val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes) val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec) @@ -66,4 +94,26 @@ internal class RainCryptoUtil @Inject constructor( return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP) } + + /** + * Formats PIN into a PIN block using schema: [Prefix][Length][PIN][fill with F]. + * Example: 246784FFFFFFFFFF for PIN 6784. + */ + private fun pinBlockByteArray(pin: String): ByteArray { + require(pin.length == PIN_LENGTH) { "PIN length must be $PIN_LENGTH" } + require(pin.all { it.isDigit() }) { "PIN must contain digits only" } + + val pinBlockHexLength = PIN_LENGTH_BYTES * 2 + val hex = buildString(pinBlockHexLength) { + append(PIN_BLOCK_ISO_9564_FORMAT_PREFIX) + append(PIN_LENGTH.toString()) + append(pin) + while (length < pinBlockHexLength) append(PIN_BLOCK_FILL_CHAR) + } + return hex.toByteArray(StandardCharsets.UTF_8) + } + + private fun ByteArray.clear() { + for (i in indices) this[i] = 0 + } } \ No newline at end of file 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..4b13cb8946 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,18 +1,25 @@ 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 by lazy { moshi.adapter(TangemPayTxHistoryResponse.Spend::class.java) } + private val paymentAdapter by lazy { moshi.adapter(TangemPayTxHistoryResponse.Payment::class.java) } + private val feeAdapter by lazy { moshi.adapter(TangemPayTxHistoryResponse.Fee::class.java) } + private val collateralAdapter by lazy { moshi.adapter(TangemPayTxHistoryResponse.Collateral::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) } ?: value.fee?.let { convertFee(id = value.id, fee = it) } + ?: value.collateral?.let { convertCollateral(id = value.id, collateral = it) } ?: run { Timber.wtf("unknown type of transaction: $value") null @@ -22,8 +29,8 @@ internal object TangemPayTxHistoryItemConverter : private fun convertSpend(id: String, spend: TangemPayTxHistoryResponse.Spend): TangemPayTxHistoryItem.Spend { return TangemPayTxHistoryItem.Spend( id = id, - // If postedAt is null, it means transaction wasn't posted and was likely declined. Use authorizedAt - date = spend.postedAt ?: spend.authorizedAt, + jsonRepresentation = spendAdapter.toJson(spend), + date = spend.authorizedAt, amount = spend.amount, currency = Currency.getInstance(spend.currency), enrichedMerchantName = spend.enrichedMerchantName, @@ -41,18 +48,39 @@ 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, ) } + + private fun convertCollateral( + id: String, + collateral: TangemPayTxHistoryResponse.Collateral, + ): TangemPayTxHistoryItem.Collateral? { + val date = collateral.postedAt ?: return run { + Timber.e("Collateral transaction postedAt is null: $collateral") + return@run null + } + return TangemPayTxHistoryItem.Collateral( + id = id, + jsonRepresentation = collateralAdapter.toJson(collateral), + date = date, + currency = Currency.getInstance("usd"), + amount = collateral.amount, + transactionHash = collateral.transactionHash, + ) + } } \ No newline at end of file diff --git a/data/wallet-connect/build.gradle.kts b/data/wallet-connect/build.gradle.kts index bed46fd58b..b7fd3d93a1 100644 --- a/data/wallet-connect/build.gradle.kts +++ b/data/wallet-connect/build.gradle.kts @@ -12,6 +12,8 @@ android { dependencies { /* Project - Domain */ + implementation(projects.domain.account) + implementation(projects.domain.account.status) implementation(projects.domain.walletConnect) implementation(projects.domain.walletConnect.models) implementation(projects.domain.transaction) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index 780142a298..47a63b25ac 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -21,6 +21,10 @@ import com.tangem.data.walletconnect.utils.WcScope import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.walletconnect.WalletConnectStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.account.supplier.SingleAccountSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService @@ -92,6 +96,7 @@ internal object WalletConnectDataModule { getWallets: GetWalletsUseCase, wcNetworksConverter: WcNetworksConverter, analytics: AnalyticsEventHandler, + accountsFeatureToggles: AccountsFeatureToggles, wcScope: WcScope, ): DefaultWcSessionsManager { return DefaultWcSessionsManager( @@ -100,6 +105,7 @@ internal object WalletConnectDataModule { getWallets = getWallets, wcNetworksConverter = wcNetworksConverter, analytics = analytics, + accountsFeatureToggles = accountsFeatureToggles, scope = wcScope, ) } @@ -179,22 +185,26 @@ internal object WalletConnectDataModule { namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>, walletManagersFacade: WalletManagersFacade, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + singleAccountSupplier: SingleAccountSupplier, ): WcNetworksConverter = WcNetworksConverter( namespaceConverters = namespaceConverters, walletManagersFacade = walletManagersFacade, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + singleAccountSupplier = singleAccountSupplier, ) @Provides @Singleton fun associateNetworksDelegate( - namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>, + networksConverter: WcNetworksConverter, + multiAccountListSupplier: MultiAccountListSupplier, getWallets: GetWalletsUseCase, - multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ): AssociateNetworksDelegate = AssociateNetworksDelegate( - namespaceConverters = namespaceConverters, getWallets = getWallets, - multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + networksConverter = networksConverter, + multiAccountListSupplier = multiAccountListSupplier, ) @Provides diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt index 705efb5df5..bd17a27eae 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt @@ -2,22 +2,25 @@ package com.tangem.data.walletconnect.pair import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.Wallet.Model.Namespace -import com.tangem.data.walletconnect.utils.WcNamespaceConverter -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.data.walletconnect.utils.WcNetworksConverter +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId 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.isLocked import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first internal class AssociateNetworksDelegate( - private val namespaceConverters: Set, + private val networksConverter: WcNetworksConverter, + private val multiAccountListSupplier: MultiAccountListSupplier, private val getWallets: GetWalletsUseCase, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ) { @Throws(WcPairError.UnsupportedBlockchains::class) @@ -29,18 +32,48 @@ internal class AssociateNetworksDelegate( .subtract(requiredNamespaces) return userWallets.associateWith { wallet -> - mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces, sessionProposal) + mapNetworksForPortfolio(wallet, null, requiredNamespaces, optionalNamespaces, sessionProposal) } } + @Throws(WcPairError.UnsupportedBlockchains::class) + suspend fun associateAccounts(sessionProposal: Wallet.Model.SessionProposal): Map { + val userWallets = getWallets.invokeSync() + .filter { it.isMultiCurrency && !it.isLocked } + val requiredNamespaces: Set = sessionProposal.requiredNamespaces.setOfChainId() + val optionalNamespaces: Set = sessionProposal.optionalNamespaces.setOfChainId() + // remove duplicates + .subtract(requiredNamespaces) + val allAccounts = multiAccountListSupplier.invoke() + .filter { it.isNotEmpty() } + .first() + .filter { accountList -> userWallets.any { accountList.userWalletId == it.walletId } } + .map { accountList -> accountList to userWallets.first { it.walletId == accountList.userWalletId } } + + return allAccounts + .map { (accountList, wallet) -> accountList.accounts.map { account -> account to wallet } } + .flatten() + .associate { (account, wallet) -> + account.accountId to mapNetworksForPortfolio( + wallet = wallet, + account = account, + requiredNamespaces = requiredNamespaces, + optionalNamespaces = optionalNamespaces, + sessionProposal = sessionProposal, + ) + } + } + @Suppress("ComplexCondition") - private suspend fun mapNetworksForWallet( + private suspend fun mapNetworksForPortfolio( wallet: UserWallet, + account: Account?, requiredNamespaces: Set, optionalNamespaces: Set, sessionProposal: Wallet.Model.SessionProposal, ): ProposalNetwork { - val walletNetworks = getWalletNetworks(userWalletId = wallet.walletId) + val portfolioNetworks = account?.let { getAccountNetworks(it.accountId) } + ?: getWalletNetworks(userWalletId = wallet.walletId) val unknownRequired = mutableSetOf() val unknownOptional = mutableSetOf() @@ -50,28 +83,28 @@ internal class AssociateNetworksDelegate( val notAdded = mutableSetOf() requiredNamespaces.forEach { chainId -> - val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) } + val wcNetwork = networksConverter.createNetwork(chainId, wallet) if (wcNetwork == null) { unknownRequired.add(missingNetworkName(chainId)) return@forEach } - val walletNetwork = walletNetworks.find { network -> wcNetwork.rawId == network.rawId } + val portfolioNetwork = portfolioNetworks.find { network -> wcNetwork.rawId == network.rawId } - if (walletNetwork == null) { + if (portfolioNetwork == null) { missingRequired.add(wcNetwork) } else { - required.add(walletNetwork) + required.add(portfolioNetwork) } } optionalNamespaces.forEach { chainId -> - val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) } + val wcNetwork = networksConverter.createNetwork(chainId, wallet) if (wcNetwork == null) { unknownOptional.add(missingNetworkName(chainId)) return@forEach } - val walletNetwork = walletNetworks.find { network -> wcNetwork.rawId == network.rawId } - if (walletNetwork != null) { - available.add(walletNetwork) + val portfolioNetwork = portfolioNetworks.find { network -> wcNetwork.rawId == network.rawId } + if (portfolioNetwork != null) { + available.add(portfolioNetwork) } else { notAdded.add(wcNetwork) } @@ -88,16 +121,18 @@ internal class AssociateNetworksDelegate( required = required, available = available, notAdded = notAdded, + account = account, ) } private suspend fun getWalletNetworks(userWalletId: UserWalletId): List { - return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), - ) - .orEmpty() - .filterIsInstance() - .map(CryptoCurrency.Coin::network) + return networksConverter.getWalletNetworks(userWalletId) + // flatten all derivation + .distinctBy { it.rawId } + } + + private suspend fun getAccountNetworks(accountId: AccountId): List { + return networksConverter.getAccountNetworks(accountId) // flatten all derivation .distinctBy { it.rawId } } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index 529f7a9302..d060e85384 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -10,6 +10,7 @@ import com.reown.walletkit.client.Wallet import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.walletconnect.utils.WC_TAG import com.tangem.data.walletconnect.utils.getDappOriginUrl +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.* @@ -34,6 +35,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( private val sdkDelegate: WcPairSdkDelegate, private val blockAidVerifier: BlockAidVerifier, private val analytics: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, @Assisted private val pairRequest: WcPairRequest, ) : WcPairUseCase { @@ -111,6 +113,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( val sessionDTO = WcSessionDTO( topic = "", walletId = sessionForApprove.wallet.walletId, + accountId = sessionForApprove.account?.accountId, url = sdkVerifyContext.getDappOriginUrl(), securityStatus = proposalState.dAppSession.securityStatus, connectingTime = connectingTime, @@ -196,6 +199,11 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( verifyContext: Wallet.Model.VerifyContext, ): Either = runCatching { val proposalNetwork = associateNetworksDelegate.associate(sessionProposal) + val proposalAccountNetwork = if (accountsFeatureToggles.isFeatureEnabled) { + associateNetworksDelegate.associateAccounts(sessionProposal) + } else { + null + } val verificationInfo = when { verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE verifyContext.isScam == true -> CheckDAppResult.UNSAFE @@ -204,7 +212,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( CheckDAppResult.FAILED_TO_VERIFY } } - val requestedNetworks = proposalNetwork + val requestedNetworks = (proposalAccountNetwork ?: proposalNetwork) .values.map { it.available.plus(it.required) }.flatten().toSet() analytics.send( WcAnalyticEvents.PairRequested( @@ -225,6 +233,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( dAppMetaData = appMetaData, proposalNetwork = proposalNetwork, securityStatus = verificationInfo, + proposalAccountNetwork = proposalAccountNetwork, ) WcPairState.Proposal(dAppSession) }.fold( diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt index 6786e8a318..848442eff1 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -8,6 +8,8 @@ import com.reown.walletkit.client.WalletKit import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.walletconnect.utils.* import com.tangem.datasource.local.walletconnect.WalletConnectStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.model.WcSession @@ -31,16 +33,20 @@ internal class DefaultWcSessionsManager( private val dispatchers: CoroutineDispatcherProvider, private val wcNetworksConverter: WcNetworksConverter, private val analytics: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, private val scope: WcScope, ) : WcSessionsManager, WcSdkObserver { private val onSessionDelete = Channel(capacity = Channel.BUFFERED) + private val oneTimeMigration = MutableStateFlow(false) override val sessions: Flow>> get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore } .transform { pair -> val (wallets, inStore) = pair val inSdk: List = WalletKit.getListOfActiveSessions() + val someMigrate = migrateToAccountSession(inStore) + if (someMigrate) return@transform val associatedSessions: List = associate(inSdk, inStore, wallets) val someRemove = removeUnknownSessions(inStore, inSdk, associatedSessions) if (someRemove) return@transform // ignore emit, wait next one @@ -49,6 +55,26 @@ internal class DefaultWcSessionsManager( .distinctUntilChanged() .flowOn(dispatchers.io) + private suspend fun migrateToAccountSession(inStore: Set): Boolean { + if (!accountsFeatureToggles.isFeatureEnabled) return false + if (oneTimeMigration.value) return false + + var someMigrated = false + + val updatedSessions = inStore.mapTo(mutableSetOf()) { sessionDTO -> + if (sessionDTO.accountId == null) { + someMigrated = true + val mainAccountId = AccountId.forMainCryptoPortfolio(sessionDTO.walletId) + sessionDTO.copy(accountId = mainAccountId) + } else { + sessionDTO + } + } + if (someMigrated) store.saveSessions(updatedSessions) + oneTimeMigration.value = true + return someMigrated + } + override fun onWcSdkInit() { listenOnSessionDelete() extendSessions() @@ -90,7 +116,11 @@ internal class DefaultWcSessionsManager( val wcSessions = savedPending.plus(inStore).mapNotNull { storeSession -> val wallet = wallets.find { it.walletId == storeSession.walletId } ?: return@mapNotNull null val sdkSession = inSdk.find { it.topic == storeSession.topic } ?: return@mapNotNull null - val networks = wcNetworksConverter.findWalletNetworks(wallet, sdkSession) + val account = storeSession.accountId?.let { wcNetworksConverter.getAccount(it) } + if (accountsFeatureToggles.isFeatureEnabled && account == null) { + return@mapNotNull null + } + val networks = wcNetworksConverter.findWalletNetworks(wallet, account, sdkSession) val originUrl = storeSession.url ?: sdkSession.metaData?.url ?: "" WcSession( wallet = wallet, @@ -102,6 +132,7 @@ internal class DefaultWcSessionsManager( ), securityStatus = storeSession.securityStatus, networks = networks, + account = account, connectingTime = storeSession.connectingTime, showWalletInfo = wallets.size > 1, ) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt index 9d998db372..880b142dbd 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt @@ -3,6 +3,13 @@ package com.tangem.data.walletconnect.utils import com.reown.walletkit.client.Wallet import com.tangem.data.common.currency.isCustomCoin import com.tangem.data.walletconnect.model.CAIP10 +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.supplier.SingleAccountSupplier +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -18,6 +25,8 @@ import javax.inject.Inject internal class WcNetworksConverter @Inject constructor( private val namespaceConverters: Set, private val walletManagersFacade: WalletManagersFacade, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val singleAccountSupplier: SingleAccountSupplier, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ) { @@ -67,8 +76,13 @@ internal class WcNetworksConverter @Inject constructor( return allCoinNetwork } - suspend fun findWalletNetworks(wallet: UserWallet, sdkSession: Wallet.Model.Session): Set { - val walletNetworks = getWalletNetworks(wallet.walletId) + suspend fun findWalletNetworks( + wallet: UserWallet, + account: Account?, + sdkSession: Wallet.Model.Session, + ): Set { + val portfolioNetworks = account?.let { getAccountNetworks(it.accountId) } + ?: getWalletNetworks(wallet.walletId) val existNetworks = sdkSession.namespaces.values .map { it.accounts }.flatten().toSet() .mapNotNull { CAIP10.fromRaw(it) } @@ -76,7 +90,7 @@ internal class WcNetworksConverter @Inject constructor( val blockchain = namespaceConverters .firstNotNullOfOrNull { it.toBlockchain(caip10.chainId) } ?: return@mapNotNullTo null - walletNetworks + portfolioNetworks // find all derivation .filter { it.rawId == blockchain.id } // find equal address @@ -89,18 +103,38 @@ internal class WcNetworksConverter @Inject constructor( return existNetworks } + suspend fun getAccount(accountId: AccountId): Account? { + return singleAccountSupplier.getSyncOrNull(SingleAccountProducer.Params(accountId)) + } + suspend fun convertNetworksForApprove(sessionForApprove: WcSessionApprove): List { - val walletNetworks = getWalletNetworks(sessionForApprove.wallet.walletId) + val portfolioNetworks = sessionForApprove.account?.let { getAccountNetworks(it.accountId) } + ?: getWalletNetworks(sessionForApprove.wallet.walletId) return sessionForApprove.network - .map { network -> walletNetworks.filter { walletNetwork -> walletNetwork.rawId == network.rawId } } + .map { network -> portfolioNetworks.filter { walletNetwork -> walletNetwork.rawId == network.rawId } } .flatten() } - private suspend fun getWalletNetworks(userWalletId: UserWalletId): List { + suspend fun getWalletNetworks(userWalletId: UserWalletId): List { return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), ) .orEmpty() .filterIsInstance().map(CryptoCurrency.Coin::network) } + + private suspend fun getAccountStatus(accountId: AccountId): AccountStatus? { + return singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(accountId.userWalletId), + )?.accountStatuses?.find { it.account.accountId == accountId } + } + + suspend fun getAccountNetworks(accountId: AccountId): List { + return getAccountStatus(accountId) + ?.flattenCurrencies() + ?.map { it.currency } + ?.filterIsInstance() + ?.map(CryptoCurrency.Coin::network) + ?: emptyList() + } } \ No newline at end of file diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt index 60a4fe2b99..6313e3ddf4 100644 --- a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -14,9 +14,13 @@ import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.utils.WcSdkSessionConverter +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletconnect.model.* +import com.tangem.domain.walletconnect.model.WcPairError +import com.tangem.domain.walletconnect.model.WcPairRequest +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.WcSessionApprove import com.tangem.domain.walletconnect.usecase.pair.WcPairState import io.mockk.coEvery import io.mockk.coVerifyOrder @@ -33,6 +37,7 @@ internal class DefaultWcPairUseCaseTest { private val analytics: AnalyticsEventHandler = mockk(relaxed = true) private val sdkDelegate: WcPairSdkDelegate = mockk() private val blockAidVerifier: BlockAidVerifier = mockk() + private val accountsFeatureToggles = mockk() private val url = "testUrl" private val source = WcPairRequest.Source.QR @@ -70,6 +75,7 @@ internal class DefaultWcPairUseCaseTest { get() = WcSessionApprove( wallet = MockUserWalletFactory.create(), network = listOf(), + account = null, ) private val sdkApprove: Wallet.Params.SessionApprove @@ -102,6 +108,7 @@ internal class DefaultWcPairUseCaseTest { networks = setOf(), connectingTime = null, showWalletInfo = false, + account = null, ) private fun useCaseFactory() = DefaultWcPairUseCase( @@ -110,12 +117,14 @@ internal class DefaultWcPairUseCaseTest { sdkDelegate = sdkDelegate, blockAidVerifier = blockAidVerifier, analytics = analytics, + accountsFeatureToggles = accountsFeatureToggles, pairRequest = WcPairRequest(userWalletId = UserWalletId(""), uri = url, source = source), ) @Before fun setup() { coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf() + coEvery { accountsFeatureToggles.isFeatureEnabled } returns false coEvery { caipNamespaceDelegate.associate( sessionProposal = sdkProposal, diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt index 15049820a7..d703676123 100644 --- a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt @@ -58,6 +58,7 @@ internal class WcSignUseCaseDelegateTest { session = WcSession( wallet = MockUserWalletFactory.create(), networks = setOf(), + account = null, securityStatus = CheckDAppResult.FAILED_TO_VERIFY, connectingTime = 0L, sdkModel = WcSdkSession( diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 56a5f027a2..692e0dce0d 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -10,20 +10,13 @@ import com.tangem.datasource.api.common.response.ApiResponseError.HttpException import com.tangem.datasource.api.common.response.fold import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.MarkUserWalletWasCreatedBody -import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody -import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO +import com.tangem.datasource.api.tangemTech.models.* import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status -import com.tangem.datasource.api.tangemTech.models.WalletBody import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys.SEED_FIRST_NOTIFICATION_SHOW_TIME -import com.tangem.datasource.local.preferences.utils.get -import com.tangem.datasource.local.preferences.utils.getObjectMap -import com.tangem.datasource.local.preferences.utils.getSyncOrDefault -import com.tangem.datasource.local.preferences.utils.getSyncOrNull -import com.tangem.datasource.local.preferences.utils.store +import com.tangem.datasource.local.preferences.utils.* import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -37,7 +30,6 @@ import com.tangem.utils.coroutines.runCatching import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.collections.mutableSetOf typealias SeedPhraseNotificationsStatuses = Map @@ -52,7 +44,7 @@ internal class DefaultWalletsRepository( ) : WalletsRepository { private val upgradeWalletNotificationDisabled: MutableStateFlow> = - MutableStateFlow(mutableSetOf()) + MutableStateFlow(mutableSetOf()) override suspend fun shouldSaveUserWalletsSync(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) @@ -260,6 +252,14 @@ internal class DefaultWalletsRepository( } } + override suspend fun createWallet(userWalletId: UserWalletId) { + withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = OnlyWalletIdBody(userWalletId.stringValue), + ) + } + } + override suspend fun rejectSeedPhraseSecondNotification(userWalletId: UserWalletId) { runCatching(dispatchers.io) { tangemTechApi.updateSeedPhraseSecondNotificationStatus( 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..5f7740206a 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 @@ -16,15 +16,17 @@ import kotlinx.serialization.Serializable * Represents a list of accounts associated with a user wallet * * @property userWalletId the user wallet id associated with the account list - * @property accounts a set of accounts belonging to the user wallet + * @property accounts a list of accounts belonging to the user wallet * @property totalAccounts the total number of accounts + * @property sortType the sorting type applied to the accounts + * @property groupType the grouping type applied to the accounts * [REDACTED_AUTHOR] */ @Serializable data class AccountList private constructor( val userWalletId: UserWalletId, - val accounts: Set, + val accounts: List, val totalAccounts: Int, val sortType: TokensSortType, val groupType: TokensGroupType, @@ -68,7 +70,7 @@ data class AccountList private constructor( */ operator fun minus(other: Account): Either { val isExistingAccount = this.accounts.any { it.accountId == other.accountId } - val accounts = this.accounts.toMutableSet().apply { + val accounts = this.accounts.toMutableList().apply { removeIf { it.accountId == other.accountId } } @@ -140,7 +142,7 @@ data class AccountList private constructor( */ operator fun invoke( userWalletId: UserWalletId, - accounts: Set, + accounts: List, totalAccounts: Int, sortType: TokensSortType = TokensSortType.NONE, groupType: TokensGroupType = TokensGroupType.NONE, @@ -190,7 +192,7 @@ data class AccountList private constructor( ): AccountList { return AccountList( userWalletId = userWalletId, - accounts = setOf( + accounts = listOf( Account.CryptoPortfolio.createMainAccount( userWalletId = userWalletId, cryptoCurrencies = cryptoCurrencies, @@ -202,7 +204,7 @@ data class AccountList private constructor( ) } - private fun Set.mainAccountsCount(): Int { + private fun List.mainAccountsCount(): Int { return count { (it as? Account.CryptoPortfolio)?.isMainAccount == true } } } diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt index 4d3b4dab0e..c817094f5a 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt @@ -1,5 +1,7 @@ package com.tangem.domain.account.models +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -13,23 +15,27 @@ import kotlinx.serialization.Serializable * @property accountStatuses a set of account statuses associated with the user wallet * @property totalAccounts the total number of accounts (including archived ones) * @property totalFiatBalance the total fiat balance across all accounts + * @property sortType the sorting type applied to the accounts + * @property groupType the grouping type applied to the accounts * [REDACTED_AUTHOR] */ @Serializable data class AccountStatusList( val userWalletId: UserWalletId, - val accountStatuses: Set, + val accountStatuses: List, val totalAccounts: Int, val totalFiatBalance: TotalFiatBalance, + val sortType: TokensSortType, + val groupType: TokensGroupType, ) { - val mainAccount: AccountStatus + val mainAccount: AccountStatus.CryptoPortfolio get() = accountStatuses.first { accountStatus -> when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.account.isMainAccount } - } + } as AccountStatus.CryptoPortfolio fun flattenCurrencies(): List = accountStatuses .map { accountStatus -> accountStatus.flattenCurrencies() } 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..8967491701 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 @@ -5,6 +5,7 @@ import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow @@ -61,6 +62,13 @@ interface AccountsCRUDRepository { */ suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) + /** + * Saves a list of accounts to the repository + * + * @param accountList the list of accounts to be saved. + */ + suspend fun saveAccountsLocally(accountList: AccountList) + /** * Saves a list of accounts to the repository * @@ -79,18 +87,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 @@ -105,4 +121,12 @@ interface AccountsCRUDRepository { /** Synchronously retrieves all user wallets */ fun getUserWalletsSync(): List + + /** Checks if the provided account name is the default name within the given account list + * + * @param accountList the list of accounts to check against + * @param accountName the account name to be checked + * @throws IllegalArgumentException if the account name matches the default name + */ + fun checkDefaultAccountName(accountList: AccountList, accountName: AccountName) } \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountSupplier.kt new file mode 100644 index 0000000000..527a32f557 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.account.Account + +/** + * Supplies instances of [SingleAccountProducer] that produce flows of [Account.CryptoPortfolio] + * for individual accounts. Each producer is uniquely identified by its [SingleAccountProducer.Params]. + * + * @property factory A factory to create instances of [SingleAccountProducer]. + * @property keyCreator A function that generates a unique key for caching based on [SingleAccountProducer.Params]. + */ +abstract class SingleAccountSupplier( + override val factory: SingleAccountProducer.Factory, + override val keyCreator: (SingleAccountProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt index 8085ff2eb1..cf7992e6ab 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt @@ -1,10 +1,12 @@ package com.tangem.domain.account.usecase import arrow.core.Either +import arrow.core.Either.Companion.catch import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either +import arrow.core.raise.withError import com.tangem.domain.account.fetcher.SingleAccountListFetcher import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.repository.AccountsCRUDRepository @@ -49,8 +51,10 @@ class AddCryptoPortfolioUseCase( val newAccount = createAccount(userWalletId, accountName, icon, derivationIndex) - val updatedAccounts = (accountList + newAccount).getOrElse { - raise(Error.AccountListRequirementsNotMet(it)) + val updatedAccounts = withError({ Error.AccountListRequirementsNotMet(it) }) { + checkDefaultName(accountList, accountName) + + (accountList + newAccount).bind() } saveAccounts(accountList = updatedAccounts) @@ -81,6 +85,12 @@ class AddCryptoPortfolioUseCase( ) } + private fun Raise.checkDefaultName(accountList: AccountList, accountName: AccountName) { + withError({ AccountList.Error.DuplicateAccountNames }) { + catch { crudRepository.checkDefaultAccountName(accountList, accountName) }.bind() + } + } + private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, 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/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt index 9b0487ea55..f70ff26efa 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt @@ -2,10 +2,7 @@ package com.tangem.domain.account.usecase import arrow.core.Either import arrow.core.getOrElse -import arrow.core.raise.Raise -import arrow.core.raise.catch -import arrow.core.raise.either -import arrow.core.raise.ensure +import arrow.core.raise.* import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.account.Account @@ -50,8 +47,12 @@ class UpdateCryptoPortfolioUseCase( .setName(name = accountName) .setIcon(icon = icon) - val updatedAccounts = (accountList + updatedAccount).getOrElse { - raise(Error.AccountListRequirementsNotMet(it)) + val updatedAccounts = withError({ Error.AccountListRequirementsNotMet(it) }) { + if (accountName != null) { + checkDefaultName(accountList, accountName) + } + + (accountList + updatedAccount).bind() } saveAccounts(updatedAccounts) @@ -88,6 +89,12 @@ class UpdateCryptoPortfolioUseCase( return if (icon != null) this.copy(icon = icon) else this } + private fun Raise.checkDefaultName(accountList: AccountList, accountName: AccountName) { + withError({ AccountList.Error.DuplicateAccountNames }) { + Either.Companion.catch { crudRepository.checkDefaultAccountName(accountList, accountName) }.bind() + } + } + /** * Represents possible errors that can occur during the update operation */ diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt index 60eea11794..96020e2f35 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt @@ -28,7 +28,7 @@ class AccountListTest { val accountList = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ) .getOrNull()!! @@ -69,7 +69,7 @@ class AccountListTest { // Assert val expected = AccountList( userWalletId = userWalletId, - accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), + accounts = listOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), totalAccounts = 1, ).getOrNull()!! @@ -96,17 +96,17 @@ class AccountListTest { private fun provideTestModels() = listOf( CreateTestModel( - accounts = emptySet(), + accounts = emptyList(), expected = AccountList.Error.EmptyAccountsList.left(), ), CreateTestModel( - accounts = setOf( + accounts = listOf( createAccount(userWalletId = userWalletId, derivationIndex = 1), ), expected = AccountList.Error.MainAccountNotFound.left(), ), CreateTestModel( - accounts = setOf( + accounts = listOf( Account.CryptoPortfolio.createMainAccount(userWalletId), Account.CryptoPortfolio.createMainAccount(userWalletId).copy( icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), @@ -131,7 +131,7 @@ class AccountListTest { expected = AccountList.Error.ExceedsMaxAccountsCount.left(), ), CreateTestModel( - accounts = setOf( + accounts = listOf( createAccount(userWalletId = userWalletId, derivationIndex = 0), createAccount(userWalletId = userWalletId, derivationIndex = 1), createAccount(userWalletId = userWalletId, derivationIndex = 1), @@ -139,7 +139,7 @@ class AccountListTest { expected = AccountList.Error.DuplicateAccountIds.left(), ), CreateTestModel( - accounts = setOf( + accounts = listOf( createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 0), createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 1), ), @@ -149,7 +149,7 @@ class AccountListTest { } data class CreateTestModel( - val accounts: Set, + val accounts: List, val expected: Either, ) @@ -176,13 +176,13 @@ class AccountListTest { PlusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toAdd = newAccount, expected = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount, newAccount), + accounts = listOf(mainAccount, newAccount), totalAccounts = 2, ), ) @@ -196,13 +196,13 @@ class AccountListTest { PlusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toAdd = newAccount, expected = AccountList( userWalletId = userWalletId, - accounts = setOf(newAccount), + accounts = listOf(newAccount), totalAccounts = 1, ), ) @@ -249,13 +249,13 @@ class AccountListTest { MinusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount, secondaryAccount), + accounts = listOf(mainAccount, secondaryAccount), totalAccounts = 2, ).getOrNull()!!, toRemove = secondaryAccount, expected = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ), ) @@ -269,13 +269,13 @@ class AccountListTest { MinusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toRemove = notInList, expected = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ), ) @@ -288,7 +288,7 @@ class AccountListTest { MinusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toRemove = mainAccount, @@ -304,7 +304,7 @@ class AccountListTest { MinusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount, secondaryAccount), + accounts = listOf(mainAccount, secondaryAccount), totalAccounts = 2, ).getOrNull()!!, toRemove = mainAccount, diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt index 32e113b374..1ea298d6e1 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt @@ -69,6 +69,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) crudRepository.saveAccounts(updatedAccountList) mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex) } @@ -177,6 +178,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) } coVerify(inverse = true) { @@ -249,6 +251,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) crudRepository.saveAccounts(updatedAccountList) } @@ -288,6 +291,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) crudRepository.saveAccounts(updatedAccountList) mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt index 5fd7b2ccce..6c926ab2ec 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt @@ -1,7 +1,5 @@ package com.tangem.domain.account.usecase -import arrow.core.None -import arrow.core.toOption import com.google.common.truth.Truth import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository @@ -12,7 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch @@ -23,6 +20,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +@Suppress("UnusedFlow") @OptIn(ExperimentalCoroutinesApi::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) class GetArchivedAccountsUseCaseTest { @@ -43,76 +41,20 @@ class GetArchivedAccountsUseCaseTest { mockk(), mockk(), ) - coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns archivedAccounts.toOption() + every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act val actual = getEmittedValues(useCase(userWalletId)) // Assert - val expected = listOf(archivedAccounts.lceContent()) + val expected = listOf( + lceLoading(), + archivedAccounts.lceContent(), + ) Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getArchivedAccountListSync(userWalletId) - crudRepository.getArchivedAccounts(userWalletId) - } - - coVerify(exactly = 0) { crudRepository.fetchArchivedAccounts(any()) } - } - - @Test - fun `invoke should emit loading and fetch when accounts not found`() = runTest { - // Arrange - val archivedAccounts = listOf( - mockk(), - mockk(), - ) - - coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None - every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) - - // Act - val actual = getEmittedValues(useCase(userWalletId)) - - // Assert - val expected = listOf( - lceLoading(), - archivedAccounts.lceContent(), - ) - Truth.assertThat(actual).isEqualTo(expected) - - coVerify(exactly = 1) { - crudRepository.getArchivedAccountListSync(userWalletId) - crudRepository.fetchArchivedAccounts(userWalletId) - crudRepository.getArchivedAccounts(userWalletId) - } - } - - @Test - fun `invoke should emit error if getArchivedAccountsSync throws exception`() = runTest { - // Arrange - val exception = IllegalStateException("Test error") - val archivedAccounts = listOf( - mockk(), - mockk(), - ) - - coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } throws exception - every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) - - // Act - val actual = getEmittedValues(useCase(userWalletId)) - - // Assert - val expected = listOf( - lceLoading(), - archivedAccounts.lceContent(), - ) - Truth.assertThat(actual).isEqualTo(expected) - - coVerify(exactly = 1) { - crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -121,11 +63,14 @@ class GetArchivedAccountsUseCaseTest { @Test fun `invoke should emit error if fetchArchivedAccounts throws exception`() = runTest { // Arrange - val exception = IllegalStateException("Fetch error") + val exception = IllegalStateException("Test error") + val archivedAccounts = listOf( + mockk(), + mockk(), + ) - coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None - every { crudRepository.getArchivedAccounts(userWalletId) } returns emptyFlow() coEvery { crudRepository.fetchArchivedAccounts(userWalletId) } throws exception + every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act val actual = getEmittedValues(useCase(userWalletId)) @@ -135,14 +80,10 @@ class GetArchivedAccountsUseCaseTest { lceLoading(), exception.lceError(), ) - Truth.assertThat(actual).isEqualTo(expected) - coVerify(exactly = 1) { - crudRepository.getArchivedAccountListSync(userWalletId) - crudRepository.fetchArchivedAccounts(userWalletId) - crudRepository.getArchivedAccounts(userWalletId) - } + coVerifyOrder { crudRepository.fetchArchivedAccounts(userWalletId) } + coVerify(inverse = true) { crudRepository.getArchivedAccounts(any()) } } @OptIn(ExperimentalCoroutinesApi::class) 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/src/test/kotlin/com/tangem/domain/account/utils/AccountExt.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/utils/AccountExt.kt index 9f452c4145..dbcbe4ead7 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/utils/AccountExt.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/utils/AccountExt.kt @@ -4,8 +4,8 @@ import com.tangem.domain.models.account.* import com.tangem.domain.models.wallet.UserWalletId import kotlin.random.Random -fun createAccounts(userWalletId: UserWalletId, count: Int): Set { - return buildSet { +fun createAccounts(userWalletId: UserWalletId, count: Int): List { + return buildList { add(Account.CryptoPortfolio.createMainAccount(userWalletId)) repeat(count - 1) { 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..311f9baa82 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 @@ -1,12 +1,12 @@ package com.tangem.domain.account.status.di import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier 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.* +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 +14,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 +22,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 @@ -30,17 +33,53 @@ internal object AccountStatusUseCaseModule { @Provides @Singleton fun provideGetAccountCurrencyByAddressUseCase( - userWalletsListRepository: UserWalletsListRepository, + accountsCRUDRepository: AccountsCRUDRepository, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, singleAccountListSupplier: SingleAccountListSupplier, ): GetAccountCurrencyByAddressUseCase { return GetAccountCurrencyByAddressUseCase( - userWalletsListRepository = userWalletsListRepository, + accountsCRUDRepository = accountsCRUDRepository, multiNetworkStatusSupplier = multiNetworkStatusSupplier, singleAccountListSupplier = singleAccountListSupplier, ) } + @Provides + @Singleton + fun provideGetCryptoCurrencyActionsUseCaseV2( + accountsCRUDRepository: AccountsCRUDRepository, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, + ): GetCryptoCurrencyActionsUseCaseV2 { + return GetCryptoCurrencyActionsUseCaseV2( + accountsCRUDRepository = accountsCRUDRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + getCryptoCurrencyActionsUseCase = getCryptoCurrencyActionsUseCase, + ) + } + + @Provides + @Singleton + fun provideApplyTokenListSortingUseCaseV2( + accountsCRUDRepository: AccountsCRUDRepository, + dispatchers: CoroutineDispatcherProvider, + ): ApplyTokenListSortingUseCaseV2 { + return ApplyTokenListSortingUseCaseV2( + accountsCRUDRepository = accountsCRUDRepository, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideGetWalletTotalBalanceUseCaseV2( + multiAccountStatusListSupplier: MultiAccountStatusListSupplier, + ): GetWalletTotalBalanceUseCaseV2 { + return GetWalletTotalBalanceUseCaseV2( + multiAccountStatusListSupplier = multiAccountStatusListSupplier, + ) + } + @Provides @Singleton fun provideGetAccountCurrencyStatusUseCase( @@ -51,31 +90,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/di/SingleAccountStatusSupplierModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/SingleAccountStatusSupplierModule.kt new file mode 100644 index 0000000000..db05d87dc9 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/SingleAccountStatusSupplierModule.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.account.status.di + +import com.tangem.domain.account.status.producer.DefaultSingleAccountStatusProducer +import com.tangem.domain.account.status.producer.SingleAccountStatusProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface SingleAccountStatusSupplierModule { + + @Binds + @Singleton + fun bindSingleAccountStatusProducerFactory( + impl: DefaultSingleAccountStatusProducer.Factory, + ): SingleAccountStatusProducer.Factory + + companion object { + + @Provides + @Singleton + fun provideSingleAccountStatusSupplier( + factory: SingleAccountStatusProducer.Factory, + ): SingleAccountStatusSupplier { + return object : SingleAccountStatusSupplier( + factory = factory, + keyCreator = { "single_account_status_${it.accountId.value}" }, + ) {} + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt index e4dabbc8cc..5acaea2b98 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt @@ -4,6 +4,8 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import kotlinx.serialization.Serializable +typealias AccountCryptoCurrencies = Map> + /** * Combines an [Account] with its corresponding [CryptoCurrency]. * diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrencyStatus.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrencyStatus.kt index 221fe1d49d..99ef55d0bf 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrencyStatus.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrencyStatus.kt @@ -4,6 +4,8 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import kotlinx.serialization.Serializable +typealias AccountCryptoCurrencyStatuses = Map> + /** * Combines an [Account] with its corresponding [CryptoCurrencyStatus]. * diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt index 29a36b58cb..e3c3fe4ad8 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt @@ -3,20 +3,23 @@ package com.tangem.domain.account.status.producer import arrow.core.Option import arrow.core.some import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn /** * Produces a flow of [AccountStatusList] for multiple user wallets. * * @property params Parameters for the producer (currently unused). - * @property userWalletsListRepository Repository to get the list of user wallets. + * @property accountsCRUDRepository Repository to get the list of user wallets. * @property singleAccountStatusListSupplier Supplier to get the account status list for a single user wallet. * @property dispatchers Coroutine dispatcher provider for managing threading. * @@ -24,7 +27,7 @@ import kotlinx.coroutines.flow.* */ internal class DefaultMultiAccountStatusListProducer @AssistedInject constructor( @Assisted val params: Unit, - private val userWalletsListRepository: UserWalletsListRepository, + private val accountsCRUDRepository: AccountsCRUDRepository, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val dispatchers: CoroutineDispatcherProvider, ) : MultiAccountStatusListProducer { @@ -33,8 +36,7 @@ internal class DefaultMultiAccountStatusListProducer @AssistedInject constructor @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow> { - return userWalletsListRepository.userWallets - .filterNotNull() + return accountsCRUDRepository.getUserWallets() .flatMapLatest { userWallets -> val flows = userWallets.map { singleAccountStatusListSupplier( 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..8d8683c595 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 @@ -4,9 +4,9 @@ import arrow.core.Option import arrow.core.none import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.core.utils.lceContent import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokensGroupType @@ -31,6 +31,7 @@ import java.math.BigDecimal * Produces a flow of [AccountStatusList] for a single user wallet. * * @property params Parameters containing the user wallet ID. + * @property accountsCRUDRepository Repository for accessing account data. * @property singleAccountListSupplier Supplier to get the list of accounts for the user wallet. * @property cryptoCurrencyStatusesFlowFactory Factory to create flows of cryptocurrency statuses. * @property dispatchers Coroutine dispatcher provider for managing threading. @@ -40,7 +41,7 @@ import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor( @Assisted private val params: SingleAccountStatusListProducer.Params, - private val userWalletsListRepository: UserWalletsListRepository, + private val accountsCRUDRepository: AccountsCRUDRepository, private val singleAccountListSupplier: SingleAccountListSupplier, private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory, private val dispatchers: CoroutineDispatcherProvider, @@ -54,36 +55,38 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ) 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 = accountsCRUDRepository.getUserWallet(userWalletId = params.userWalletId) + + getAccountStatusFlow( + userWallet = userWallet, + account = account, + groupType = accountList.groupType, + sortType = accountList.sortType, + ) } + .distinctUntilChanged() + } combine(accountStatusFlows) { accountStatuses -> val balances = accountStatuses.map { it.tokenList.totalFiatBalance } AccountStatusList( userWalletId = accountList.userWalletId, - accountStatuses = accountStatuses.toSet(), + accountStatuses = accountStatuses.toList(), totalAccounts = accountList.totalAccounts, totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), + sortType = accountList.sortType, + groupType = accountList.groupType, ) } } + .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/producer/DefaultSingleAccountStatusProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusProducer.kt new file mode 100644 index 0000000000..86377c035e --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusProducer.kt @@ -0,0 +1,44 @@ +package com.tangem.domain.account.status.producer + +import arrow.core.Option +import arrow.core.none +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.models.account.AccountStatus +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.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleAccountStatusProducer]. + * + * @param params Parameters required to produce the account status. + * @param singleAccountStatusListSupplier Supplier to fetch the list of account statuses. + * @param dispatchers Coroutine dispatcher provider for managing threading. + */ +internal class DefaultSingleAccountStatusProducer @AssistedInject constructor( + @Assisted val params: SingleAccountStatusProducer.Params, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleAccountStatusProducer { + + override val fallback: Option get() = none() + + override fun produce(): Flow { + return singleAccountStatusListSupplier(userWalletId = params.accountId.userWalletId) + .mapNotNull { accountStatusList -> + accountStatusList.accountStatuses.firstOrNull { + it.account.accountId == params.accountId + } + } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleAccountStatusProducer.Factory { + override fun create(params: SingleAccountStatusProducer.Params): DefaultSingleAccountStatusProducer + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/SingleAccountStatusProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/SingleAccountStatusProducer.kt new file mode 100644 index 0000000000..1a649fedbc --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/SingleAccountStatusProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.account.status.producer + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus + +/** + * Producer that provides a single [AccountStatus] for a specific account identifier. + * +[REDACTED_AUTHOR] + */ +interface SingleAccountStatusProducer : FlowProducer { + + data class Params(val accountId: AccountId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt index 60ed1d5971..b37d457efc 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt @@ -3,6 +3,7 @@ package com.tangem.domain.account.status.supplier import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.MultiAccountStatusListProducer import com.tangem.domain.core.flow.FlowCachingSupplier +import kotlinx.coroutines.flow.Flow /** * Supplier that provides a list of [AccountStatusList]s for all user wallets. @@ -12,4 +13,9 @@ import com.tangem.domain.core.flow.FlowCachingSupplier abstract class MultiAccountStatusListSupplier( override val factory: MultiAccountStatusListProducer.Factory, override val keyCreator: (Unit) -> String, -) : FlowCachingSupplier>() \ No newline at end of file +) : FlowCachingSupplier>() { + + operator fun invoke(): Flow> { + return super.invoke(params = Unit) + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusSupplier.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusSupplier.kt new file mode 100644 index 0000000000..f00b6d0a35 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusSupplier.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.account.status.supplier + +import com.tangem.domain.account.status.producer.SingleAccountStatusProducer +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.account.AccountStatus + +/** + * Supplier that provides a single [AccountStatus] for a specific account identifier. + * +[REDACTED_AUTHOR] + */ +abstract class SingleAccountStatusSupplier( + override val factory: SingleAccountStatusProducer.Factory, + override val keyCreator: (SingleAccountStatusProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseV2.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseV2.kt new file mode 100644 index 0000000000..a3a2854c45 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseV2.kt @@ -0,0 +1,203 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.* +import arrow.core.raise.* +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.model.AccountCryptoCurrencies +import com.tangem.domain.core.utils.eitherOn +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +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.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListSortingError +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import timber.log.Timber + +private typealias SortingErrorByAccountId = MutableMap + +/** + * Use case to apply token list sorting and grouping preferences for accounts. + * + * @property accountsCRUDRepository Repository for CRUD operations on accounts. + * @property dispatchers Coroutine dispatcher provider for managing threading. + */ +class ApplyTokenListSortingUseCaseV2( + private val accountsCRUDRepository: AccountsCRUDRepository, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Applies the token list sorting and grouping preferences for the given accounts. + * + * @param sortedTokensIdsByAccount A map of accounts to their sorted token IDs. + * @param isGroupedByNetwork Boolean indicating if tokens should be grouped by network. + * @param isSortedByBalance Boolean indicating if tokens should be sorted by balance. + * @return Either a [TokenListSortingError] or Unit on success. + */ + suspend operator fun invoke( + sortedTokensIdsByAccount: AccountCryptoCurrencies, + isGroupedByNetwork: Boolean, + isSortedByBalance: Boolean, + ): Either = eitherOn(dispatchers.default) { + ensure(sortedTokensIdsByAccount.isNotEmpty()) { + raise(TokenListSortingError.TokenListIsEmpty) + } + + val userWalletId = sortedTokensIdsByAccount.keys.first().userWalletId + + val accountList = getAccountList(userWalletId) + + val maybeSortedAccountList = sortAccountList( + accountList = accountList, + sortedTokensIdsByAccount = sortedTokensIdsByAccount, + isSortedByBalance = isSortedByBalance, + isGroupedByNetwork = isGroupedByNetwork, + ) + + val sortedAccountList = maybeSortedAccountList.getOrNull() + val isSortingTypeChanged = accountList.isSortedByBalance() != isSortedByBalance + val isGroupingTypeChanged = accountList.isGroupedByNetwork() != isGroupedByNetwork + + if (sortedAccountList != null || isSortingTypeChanged || isGroupingTypeChanged) { + applySorting(sortedAccountList ?: accountList) + } + + maybeSortedAccountList.toEitherNeg() + .onLeft { errorByAccountId -> + Timber.e( + """ + Unable to sort tokens for accounts: ${ + errorByAccountId.entries.joinToString { "${it.key.value}: ${it.value}" } + } + """.trimIndent(), + ) + + raise(TokenListSortingError.UnableToSortAccounts(errorByAccountId.keys.map { it.value })) + } + } + + private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { + return catchOrDataError { + accountsCRUDRepository.getAccountListSync(userWalletId = userWalletId).getOrElse { + throw IllegalStateException("Account list not found for wallet $userWalletId") + } + } + } + + private fun AccountList.isSortedByBalance(): Boolean = sortType == TokensSortType.BALANCE + + private fun AccountList.isGroupedByNetwork(): Boolean = groupType == TokensGroupType.NETWORK + + private fun Raise.sortAccountList( + accountList: AccountList, + sortedTokensIdsByAccount: AccountCryptoCurrencies, + isSortedByBalance: Boolean, + isGroupedByNetwork: Boolean, + ): Ior { + val errors = mutableMapOf() + + val updatedAccountList = AccountList( + userWalletId = accountList.userWalletId, + accounts = accountList.accounts.sortTokens(sortedTokensIdsByAccount, errors), + totalAccounts = accountList.totalAccounts, + sortType = if (isSortedByBalance) TokensSortType.BALANCE else TokensSortType.NONE, + groupType = if (isGroupedByNetwork) TokensGroupType.NETWORK else TokensGroupType.NONE, + ).getOrElse { + raise(TokenListSortingError.UnableToSortTokenList) + } + + return if (errors.isEmpty()) { + updatedAccountList.rightIor() + } else if (errors.size == sortedTokensIdsByAccount.keys.size) { + errors.leftIor() + } else { + (errors to updatedAccountList).bothIor() + } + } + + private fun List.sortTokens( + sortedTokensIdsByAccount: AccountCryptoCurrencies, + errors: MutableMap, + ): List { + return map { account -> + if (account !is Account.CryptoPortfolio) return@map account + + val sortedCurrencies = sortedTokensIdsByAccount[account] ?: return@map account + + val accountCurrencies = account.cryptoCurrencies.toList() + .sortTokens(sortedCurrencies = sortedCurrencies) + .getOrElse { + errors[account.accountId] = it + return@map account + } + .toSet() + + account.copy(cryptoCurrencies = accountCurrencies) + } + } + + private fun List.sortTokens( + sortedCurrencies: List, + ): Either> = either { + val nonEmptySortedTokensIds = ensureNotNull(sortedCurrencies.toNonEmptySetOrNull()) { + TokenListSortingError.TokenListIsEmpty + } + + val sortedTokens = sortedMapOf() + + distinct().forEach { currency -> + val index = nonEmptySortedTokensIds.indexOfFirst { sortedCurrency -> + sortedCurrency.id == currency.id + } + + if (index >= 0) { + sortedTokens[index] = currency + } else { + raise(TokenListSortingError.UnableToSortTokenList) + } + } + + ensureNotNull(sortedTokens.values.toNonEmptyListOrNull()) { + TokenListSortingError.TokenListIsEmpty + } + } + + private suspend fun Raise.applySorting(accountList: AccountList) { + coroutineScope { + val results = awaitAll( + async { + Either.catch { accountsCRUDRepository.saveAccountsLocally(accountList) } + }, + async { + Either.catch { accountsCRUDRepository.syncTokens(accountList.userWalletId) } + }, + ) + + ensure(results.none { it.isLeft() }) { + val message = results.mapNotNull { it.leftOrNull() }.joinToString() + raise(TokenListSortingError.DataError(IllegalStateException(message))) + } + } + } + + private fun Ior.toEitherNeg(): Either { + return when (this) { + is Ior.Left -> this.value.left() + is Ior.Right -> this.value.right() + is Ior.Both -> this.leftValue.left() + } + } + + private suspend fun Raise.catchOrDataError(block: suspend () -> A): A { + return catch( + block = { block() }, + catch = { raise(TokenListSortingError.DataError(it)) }, + ) + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt index 18abf00e6c..b29d088ba7 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt @@ -9,9 +9,9 @@ import arrow.core.raise.option import arrow.core.toNonEmptyListOrNull import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.model.AccountCryptoCurrency import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.getAddress @@ -29,14 +29,14 @@ private typealias WalletIdWithNetworkId = Pair /** * Use case to retrieve an [AccountCryptoCurrency] based on a provided address. * - * @property userWalletsListRepository Repository to access user wallets. + * @property accountsCRUDRepository Repository to access user wallets. * @property multiNetworkStatusSupplier Supplier to get network status for multiple networks. * @property singleAccountListSupplier Supplier to get account lists for a single wallet. * [REDACTED_AUTHOR] */ class GetAccountCurrencyByAddressUseCase( - private val userWalletsListRepository: UserWalletsListRepository, + private val accountsCRUDRepository: AccountsCRUDRepository, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val singleAccountListSupplier: SingleAccountListSupplier, ) { @@ -65,7 +65,7 @@ class GetAccountCurrencyByAddressUseCase( } private fun OptionRaise.getUserWalletIds(): NonEmptyList { - val userWalletIds = userWalletsListRepository.userWallets.value.orEmpty() + val userWalletIds = accountsCRUDRepository.getUserWalletsSync() .filter(UserWallet::isMultiCurrency) .map(UserWallet::walletId) .toNonEmptyListOrNull() diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt index 730bff6aa4..3a6fed2955 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt @@ -2,13 +2,17 @@ package com.tangem.domain.account.status.usecase import arrow.core.Option import arrow.core.none +import arrow.core.some import arrow.core.toOption import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatuses import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusFinder +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow @@ -50,15 +54,28 @@ class GetAccountCurrencyStatusUseCase( currencyId: CryptoCurrency.ID, network: Network?, ): Option { - val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull( - params = SingleAccountStatusListProducer.Params(userWalletId), - ) ?: return none() + val accountStatusList = getAccountStatusListSync(userWalletId) ?: return none() return accountStatusList .toAccountCryptoCurrencyStatus(currencyId, network) .toOption() } + suspend fun invokeSync( + userWalletId: UserWalletId, + currencies: List, + ): Option { + if (currencies.isEmpty()) return emptyMap>().some() + + val accountStatusList = getAccountStatusListSync(userWalletId) ?: return none() + + return AccountCryptoCurrencyStatusFinder( + accountStatusList = accountStatusList, + currencies = currencies, + ) + .toOption() + } + /** * Retrieves the status of a specific cryptocurrency for a given user wallet as a [Flow]. * @@ -91,6 +108,12 @@ class GetAccountCurrencyStatusUseCase( } } + private suspend fun getAccountStatusListSync(userWalletId: UserWalletId): AccountStatusList? { + return singleAccountStatusListSupplier.getSyncOrNull( + params = SingleAccountStatusListProducer.Params(userWalletId), + ) + } + private fun AccountStatusList.toAccountCryptoCurrencyStatus( currencyId: CryptoCurrency.ID, network: Network?, diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCaseV2.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCaseV2.kt new file mode 100644 index 0000000000..0bae08d46c --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetWalletTotalBalanceUseCaseV2.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.account.status.usecase + +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.core.utils.lceContent +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListError +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Use case to get total fiat balances for multiple user wallets. + * It's second version of the use case that will replace the previous one after accounts feature released. + * + * @param multiAccountStatusListSupplier Supplier that provides the status list for multiple accounts. + */ +class GetWalletTotalBalanceUseCaseV2( + private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, +) { + + operator fun invoke( + userWalletIds: Collection, + ): LceFlow> { + return multiAccountStatusListSupplier() + .map { accountStatusLists -> + accountStatusLists + .filter { it.userWalletId in userWalletIds } + .associate { + it.userWalletId to it.totalFiatBalance + } + .lceContent() + } + .distinctUntilChanged() + } +} \ No newline at end of file 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 74% 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..ca59ae9ed8 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, ) { @@ -83,12 +84,28 @@ class SaveCryptoCurrenciesUseCase( account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()), ) - derivePublicKeys(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + val isDerivingFailed = derivePublicKeys( + userWalletId = userWalletId, + currencies = modifiedCurrencyList.added, + ).isLeft() - val jobs = refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + + 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. + */ + val isOnlyRemoval = modifiedCurrencyList.added.isEmpty() && modifiedCurrencyList.removed.isNotEmpty() + + if (isDerivingFailed || isOnlyRemoval) { + launch { accountsCRUDRepository.syncTokens(userWalletId) } + } + + if (isDerivingFailed) return@launch + + cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed) - - jobs.joinAll() + } } } @@ -114,7 +131,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 +155,7 @@ class SaveCryptoCurrenciesUseCase( private fun Set.modify( add: List, - remove: List, + remove: List = emptyList(), ): ModifiedCurrencyList { val mutableCurrencies = this.toMutableList() val added = mutableListOf() @@ -164,9 +188,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) { @@ -188,14 +211,11 @@ class SaveCryptoCurrenciesUseCase( ) } - private suspend fun Raise.derivePublicKeys( + private suspend fun derivePublicKeys( userWalletId: UserWalletId, currencies: List, - ) { - catch( - block = { derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) }, - catch = ::raise, - ) + ): Either = Either.catch { + derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) } private fun List.groupByNetwork( @@ -234,52 +254,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 67% 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..d2eb7a9b1c 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,7 +1,8 @@ -package com.tangem.domain.account.usecase +package com.tangem.domain.account.status.usecase import arrow.core.Either import arrow.core.getOrElse +import arrow.core.merge import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either @@ -9,9 +10,12 @@ 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.AccountNameIndexer +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 +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWalletId /** @@ -25,6 +29,7 @@ import com.tangem.domain.models.wallet.UserWalletId class RecoverCryptoPortfolioUseCase( private val crudRepository: AccountsCRUDRepository, private val mainAccountTokensMigration: MainAccountTokensMigration, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, ) { /** @@ -43,8 +48,7 @@ class RecoverCryptoPortfolioUseCase( val recoveredAccount = archivedAccount.recover() - val updatedAccountList = (accountList + recoveredAccount) - .getOrElse { raise(Error.AccountListRequirementsNotMet(cause = it)) } + val updatedAccountList = add(accountList, recoveredAccount) saveAccounts(updatedAccountList) @@ -53,6 +57,8 @@ class RecoverCryptoPortfolioUseCase( derivationIndex = recoveredAccount.derivationIndex, ) + refreshBalances(accountId = accountId) + recoveredAccount } @@ -93,6 +99,42 @@ class RecoverCryptoPortfolioUseCase( ) } + private fun Raise.add(accountList: AccountList, recoveredAccount: Account.CryptoPortfolio): AccountList { + val maybeAccountList = accountList + recoveredAccount + + return maybeAccountList.mapLeft { + val recoveredAccountName = recoveredAccount.accountName as? AccountName.Custom + ?: raise(Error.DataOperationFailed(message = "Recovered account should have a custom name")) + + val indexedName = AccountNameIndexer.transform(from = recoveredAccountName.value) + .let(AccountName::invoke).getOrNull() + ?: raise(Error.DataOperationFailed(message = "Failed to generate indexed account name")) + + val renamedAccount = recoveredAccount.copy(accountName = indexedName) + + // recursively try to add the account with the new name + add(accountList = accountList, recoveredAccount = renamedAccount) + } + .merge() + } + + 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/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt index 6f29996eab..f00df7290d 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -4,6 +4,7 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatuses import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.DerivationIndex @@ -50,6 +51,26 @@ internal object AccountCryptoCurrencyStatusFinder { .firstOrNull() } + operator fun invoke( + accountStatusList: AccountStatusList, + currencies: List, + ): AccountCryptoCurrencyStatuses { + if (currencies.isEmpty()) return emptyMap() + + val currencyIds = currencies.map(CryptoCurrency::id).toSet() + val networks = currencies.map(CryptoCurrency::network).distinct() + + return accountStatusList.getExpectedAccountStatuses(networks) + .asSequence() + .filterIsInstance() + .associate { accountStatus -> + val statuses = accountStatus.flattenCurrencies() + .filter { it.currency.id in currencyIds } + + accountStatus.account to statuses + } + } + /** * Retrieves the expected account statuses based on the provided [network]. * If the [network] is null, all account statuses are returned. @@ -58,14 +79,14 @@ internal object AccountCryptoCurrencyStatusFinder { * @param network the network to filter accounts by, can be null. * @return a set of [AccountStatus] that match the expected criteria. */ - private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): Set { + private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): List { val possibleAccountIndex = network?.getAccountIndexOrNull() return when (possibleAccountIndex) { // currency can be in any account null -> accountStatuses // currency only in the main account - DerivationIndex.Main.value -> setOf(mainAccount) + DerivationIndex.Main.value -> listOf(mainAccount) // currency only in the account with specific derivation index or in the main account else -> { val accountStatus = accountStatuses.firstOrNull { @@ -74,11 +95,25 @@ internal object AccountCryptoCurrencyStatusFinder { cryptoPortfolio.derivationIndex.value == possibleAccountIndex } - setOfNotNull(accountStatus, mainAccount) + listOfNotNull(accountStatus, mainAccount) } } } + private fun AccountStatusList.getExpectedAccountStatuses(networks: List): List { + val possibleAccountIndexes = networks.mapNotNull { it.getAccountIndexOrNull() } + + if (possibleAccountIndexes.isEmpty()) return this@getExpectedAccountStatuses.accountStatuses + + val accountStatuses = this@getExpectedAccountStatuses.accountStatuses.filter { + val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@filter false + + cryptoPortfolio.derivationIndex.value in possibleAccountIndexes + } + + return accountStatuses + listOf(mainAccount) + } + private fun Network.getAccountIndexOrNull(): Int? { val blockchain = Blockchain.fromNetworkId(networkId = rawId) ?: return null val recognizer = AccountNodeRecognizer(blockchain) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountNameIndexer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountNameIndexer.kt new file mode 100644 index 0000000000..5062d2c5b8 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountNameIndexer.kt @@ -0,0 +1,63 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.models.account.AccountName + +/** + * Utility object for indexing account names to ensure uniqueness. + * + * @see [iOS](https://github.com/tangem-developments/tangem-app-ios/blob/32c56f1a566ecec00364ffeaf6b3c9929419ad15/Tangem/Domain/Accounts/Archiving/Utils/UnarchivedCryptoAccountNameIndexer.swift#L11) + * +[REDACTED_AUTHOR] + */ +internal object AccountNameIndexer { + + private const val INITIAL_INDEX = 1 + private const val INDEX_PREFIX = "(" + private const val INDEX_SUFFIX = ")" + + /** + * Transforms the given account name by appending or incrementing an index suffix to ensure uniqueness. + * + * @param from the original account name + * + * @return the transformed account name with an updated index suffix + */ + fun transform(from: String): String { + val (newIndex, currentIndex) = extractIndices(from) + val accountNameSuffix = makeStringFromIndex(newIndex) + val accountNamePrefixLength = (AccountName.MAX_LENGTH - accountNameSuffix.length).coerceAtLeast(0) + + var accountNamePrefix = from + if (currentIndex != null) { + val accountNameCurrentSuffix = makeStringFromIndex(currentIndex) + val accountNameCurrentSuffixLength = accountNameCurrentSuffix.length + accountNamePrefix = accountNamePrefix.dropLast(accountNameCurrentSuffixLength) + } + + accountNamePrefix = accountNamePrefix.take(accountNamePrefixLength) + + return accountNamePrefix + accountNameSuffix + } + + private fun extractIndices(string: String): Pair { + val pattern = """\${INDEX_PREFIX}(\d+)\${INDEX_SUFFIX}$""" + val regex = Regex(pattern) + val match = regex.find(string) + + return if (match != null && match.groupValues.size > 1) { + val currentIndex = match.groupValues[1].toIntOrNull() + + if (currentIndex != null) { + Pair(currentIndex + 1, currentIndex) + } else { + Pair(INITIAL_INDEX, null) + } + } else { + Pair(INITIAL_INDEX, null) + } + } + + private fun makeStringFromIndex(index: Int): String { + return "${INDEX_PREFIX}$index${INDEX_SUFFIX}" + } +} \ No newline at end of file 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/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt index a3becc8abf..9dc8e4e5b6 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt @@ -3,8 +3,8 @@ package com.tangem.domain.account.status.producer import com.google.common.truth.Truth import com.tangem.common.test.utils.getEmittedValues import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -20,7 +20,7 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultMultiAccountStatusListProducerTest { - private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val accountsCRUDRepository: AccountsCRUDRepository = mockk() private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk() private val dispatchers = TestingCoroutineDispatcherProvider() @@ -36,14 +36,14 @@ class DefaultMultiAccountStatusListProducerTest { private val producer = DefaultMultiAccountStatusListProducer( params = Unit, - userWalletsListRepository = userWalletsListRepository, + accountsCRUDRepository = accountsCRUDRepository, singleAccountStatusListSupplier = singleAccountStatusListSupplier, dispatchers = dispatchers, ) @AfterEach fun tearDown() { - clearMocks(userWalletsListRepository, singleAccountStatusListSupplier) + clearMocks(accountsCRUDRepository, singleAccountStatusListSupplier) } @Test @@ -52,7 +52,7 @@ class DefaultMultiAccountStatusListProducerTest { val wallets = listOf(userWallet1, userWallet2) val walletsFlow = MutableStateFlow(wallets) - every { userWalletsListRepository.userWallets } returns walletsFlow + every { accountsCRUDRepository.getUserWallets() } returns walletsFlow val accountStatusList1 = mockk() val accountStatusList2 = mockk() @@ -76,7 +76,7 @@ class DefaultMultiAccountStatusListProducerTest { Truth.assertThat(actual).containsExactly(expected) coVerify(ordering = Ordering.SEQUENCE) { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWallets() singleAccountStatusListSupplier( params = SingleAccountStatusListProducer.Params(userWalletId1), ) @@ -90,7 +90,7 @@ class DefaultMultiAccountStatusListProducerTest { fun `produce returns empty flow if userWallets is empty list`() = runTest { // Arrange val walletsFlow = MutableStateFlow>(emptyList()) - every { userWalletsListRepository.userWallets } returns walletsFlow + every { accountsCRUDRepository.getUserWallets() } returns walletsFlow // Act val actual = producer.produce().let(::getEmittedValues) @@ -99,26 +99,27 @@ class DefaultMultiAccountStatusListProducerTest { Truth.assertThat(actual).isEmpty() coVerify(ordering = Ordering.SEQUENCE) { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWallets() } } - @Test - fun `produce returns empty flow if userWallets is null`() = runTest { - // Arrange - val walletsFlow = MutableStateFlow?>(null) - every { userWalletsListRepository.userWallets } returns walletsFlow - - // Act - val actual = producer.produce().let(::getEmittedValues) - - // Assert - Truth.assertThat(actual).isEmpty() - - coVerify(ordering = Ordering.SEQUENCE) { - userWalletsListRepository.userWallets - } - } + // TODO: uncomment after migration on UserWalletsListRepository + // @Test + // fun `produce returns empty flow if userWallets is null`() = runTest { + // // Arrange + // val walletsFlow = MutableStateFlow?>(null) + // every { accountsCRUDRepository.getUserWallets() } returns walletsFlow + // + // // Act + // val actual = producer.produce().let(::getEmittedValues) + // + // // Assert + // Truth.assertThat(actual).isEmpty() + // + // coVerify(ordering = Ordering.SEQUENCE) { + // accountsCRUDRepository.getUserWallets() + // } + // } @Test fun `flow will updated if userWallets are updated`() = runTest { @@ -127,7 +128,7 @@ class DefaultMultiAccountStatusListProducerTest { val userWallet3 = mockk { every { walletId } returns userWalletId3 } val walletsFlow = MutableStateFlow(listOf(userWallet1, userWallet2)) - every { userWalletsListRepository.userWallets } returns walletsFlow + every { accountsCRUDRepository.getUserWallets() } returns walletsFlow val accountStatusList1 = mockk() val accountStatusList2 = mockk() @@ -167,7 +168,7 @@ class DefaultMultiAccountStatusListProducerTest { Truth.assertThat(actual2).containsExactly(expected2) coVerify(ordering = Ordering.SEQUENCE) { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWallets() singleAccountStatusListSupplier( params = SingleAccountStatusListProducer.Params(userWalletId1), ) @@ -183,7 +184,7 @@ class DefaultMultiAccountStatusListProducerTest { singleAccountStatusListSupplier( params = SingleAccountStatusListProducer.Params(userWalletId3), ) - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWallets() singleAccountStatusListSupplier( params = SingleAccountStatusListProducer.Params(userWalletId1), ) diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt index 6601e4c64e..0452a2df67 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt @@ -7,9 +7,9 @@ import com.tangem.common.test.utils.getEmittedValues import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.models.StatusSource @@ -38,7 +38,7 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultSingleAccountStatusListProducerTest { - private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val accountsCRUDRepository: AccountsCRUDRepository = mockk() private val singleAccountListSupplier: SingleAccountListSupplier = mockk() private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk() @@ -49,7 +49,7 @@ class DefaultSingleAccountStatusListProducerTest { private val producer = DefaultSingleAccountStatusListProducer( params = SingleAccountStatusListProducer.Params(userWalletId), - userWalletsListRepository = userWalletsListRepository, + accountsCRUDRepository = accountsCRUDRepository, singleAccountListSupplier = singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory, dispatchers = TestingCoroutineDispatcherProvider(), @@ -57,7 +57,7 @@ class DefaultSingleAccountStatusListProducerTest { @AfterEach fun tearDown() { - clearMocks(singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory) + clearMocks(accountsCRUDRepository, singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory) } @Test @@ -75,7 +75,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -84,6 +84,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual).containsExactly(expected) @@ -110,7 +112,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (first emission) val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -119,6 +121,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual1).containsExactly(expected) @@ -129,7 +133,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (second emission) val expected2 = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = updatedAccountList.mainAccount, tokenList = TokenList.Empty, @@ -138,6 +142,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = updatedAccountList.sortType, + groupType = updatedAccountList.groupType, ) Truth.assertThat(actual2).containsExactly(expected2) @@ -159,7 +165,7 @@ class DefaultSingleAccountStatusListProducerTest { val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -168,6 +174,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) // Act (first emission) @@ -198,7 +206,7 @@ class DefaultSingleAccountStatusListProducerTest { cryptoCurrencies = cryptoCurrencyFactory.ethereumAndStellar.toSet(), ) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + coEvery { accountsCRUDRepository.getUserWallet(userWalletId = userWalletId) } returns userWallet every { singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) @@ -226,7 +234,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Ungrouped( @@ -239,6 +247,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loading, + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual).containsExactly(expected) diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseTest.kt new file mode 100644 index 0000000000..48026d148b --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseTest.kt @@ -0,0 +1,375 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.left +import arrow.core.right +import arrow.core.some +import com.google.common.truth.Truth +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.CryptoPortfolioIcon +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.error.TokenListSortingError +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ApplyTokenListSortingUseCaseTest { + + private val accountsCRUDRepository = mockk(relaxUnitFun = true) + + private val useCase = ApplyTokenListSortingUseCaseV2( + accountsCRUDRepository = accountsCRUDRepository, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId(stringValue = "011") + + @AfterEach + fun tearDown() { + clearMocks(accountsCRUDRepository) + } + + @Test + fun `when tokens are empty then error should be received`() = runTest { + // Act + val actual = useCase( + sortedTokensIdsByAccount = emptyMap(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + + // Assert + val expected = TokenListSortingError.TokenListIsEmpty.left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder(inverse = true) { + accountsCRUDRepository.getAccountListSync(userWalletId = any()) + accountsCRUDRepository.saveAccountsLocally(accountList = any()) + accountsCRUDRepository.syncTokens(userWalletId = any()) + } + } + + @Test + fun `when getAccountListSync throws exception then error should be received`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet()) + + val exception = IllegalStateException("No internet connection") + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } throws exception + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ) + + // Assert + val expected = TokenListSortingError.DataError(exception).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) } + + coVerifyOrder(inverse = true) { + accountsCRUDRepository.saveAccountsLocally(accountList = any()) + accountsCRUDRepository.syncTokens(userWalletId = any()) + } + } + + @Test + fun `when saveAccountsLocally throws exception then error should be received`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet()) + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val exception = IllegalStateException("No internet connection") + coEvery { accountsCRUDRepository.saveAccountsLocally(accountList) } throws exception + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ).leftOrNull() as TokenListSortingError.DataError + + // Assert + val expected = TokenListSortingError.DataError(IllegalStateException(exception.toString())) + Truth.assertThat(actual.cause).isInstanceOf(expected.cause::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.cause.message) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(accountList) + accountsCRUDRepository.syncTokens(userWalletId = userWalletId) + } + } + + @Test + fun `when syncTokens throws exception then error should be received`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet()) + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val exception = IllegalStateException("No internet connection") + coEvery { accountsCRUDRepository.syncTokens(userWalletId) } throws exception + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ).leftOrNull() as TokenListSortingError.DataError + + // Assert + val expected = TokenListSortingError.DataError(IllegalStateException(exception.toString())) + Truth.assertThat(actual.cause).isInstanceOf(expected.cause::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.cause.message) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(accountList) + accountsCRUDRepository.syncTokens(userWalletId = userWalletId) + } + } + + @Test + fun `when apply sorting for sorted and grouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = true, + isSortedByBalance = true, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when apply sorting for unsorted and grouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NETWORK, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when apply sorting for sorted and ungrouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NONE, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = false, + isSortedByBalance = true, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when apply sorting for unsorted and ungrouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when sorted tokens IDs do not contain all tokens IDs then error should be received`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val customAccount = Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(1).getOrNull()!!), + name = "Account 1", + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = 1, + cryptoCurrencies = setOf(token1, token2, token3), + ) + .getOrNull()!! + + val accountList = (AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + customAccount).getOrNull()!! + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokensIdsByAccount: Map> = mapOf( + accountList.mainAccount to listOf(token3, token2, token1), + customAccount to listOf(token2, token1), // missing token3 + ) + + val updatedAccountList = AccountList( + userWalletId = userWalletId, + accounts = listOf( + accountList.mainAccount.copy(cryptoCurrencies = setOf(token3, token2, token1)), + customAccount, // unchanged due to error + ), + totalAccounts = accountList.totalAccounts, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + .getOrNull()!! + + // Act + val actual = useCase( + sortedTokensIdsByAccount = sortedTokensIdsByAccount, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + + // Assert + val expected = TokenListSortingError.UnableToSortAccounts( + accountIds = listOf(customAccount.accountId.value), + ).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt index 34b63f7842..7eff6a7d85 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt @@ -5,9 +5,9 @@ import com.tangem.common.test.utils.assertNone import com.tangem.common.test.utils.assertSome import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.model.AccountCryptoCurrency import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus @@ -17,7 +17,6 @@ import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import io.mockk.* -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -29,19 +28,19 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class GetAccountCurrencyByAddressUseCaseTest { - private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val accountsCRUDRepository: AccountsCRUDRepository = mockk() private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier = mockk() private val singleAccountListSupplier: SingleAccountListSupplier = mockk() private val useCase = GetAccountCurrencyByAddressUseCase( - userWalletsListRepository = userWalletsListRepository, + accountsCRUDRepository = accountsCRUDRepository, multiNetworkStatusSupplier = multiNetworkStatusSupplier, singleAccountListSupplier = singleAccountListSupplier, ) @AfterEach fun tearDown() { - clearMocks(userWalletsListRepository, multiNetworkStatusSupplier, singleAccountListSupplier) + clearMocks(accountsCRUDRepository, multiNetworkStatusSupplier, singleAccountListSupplier) } @Test @@ -56,26 +55,27 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) } - @Test - fun `returns None if userWalletIds is null`() = runTest { - // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) - - // Act - val actual = useCase(validAddress) - - // Assert - assertNone(actual) - - coVerifySequence { - userWalletsListRepository.userWallets - } - } + // TODO: uncomment after migration on UserWalletsListRepository + // @Test + // fun `returns None if userWalletIds is null`() = runTest { + // // Arrange + // every { accountsCRUDRepository.getUserWalletsSync() } returns MutableStateFlow(null) + // + // // Act + // val actual = useCase(validAddress) + // + // // Assert + // assertNone(actual) + // + // coVerifySequence { + // accountsCRUDRepository.getUserWalletsSync() + // } + // } @Test fun `returns None if userWalletIds is empty list`() = runTest { // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(emptyList()) + every { accountsCRUDRepository.getUserWalletsSync() } returns emptyList() // Act val actual = useCase(validAddress) @@ -84,7 +84,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() } } @@ -95,7 +95,7 @@ class GetAccountCurrencyByAddressUseCaseTest { every { isMultiCurrency } returns false } - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(singleWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(singleWallet) // Act val actual = useCase(validAddress) @@ -104,14 +104,14 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() } } @Test fun `returns None if multiNetworkStatusSupplier returns null`() = runTest { // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns null @@ -123,7 +123,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } } @@ -131,7 +131,7 @@ class GetAccountCurrencyByAddressUseCaseTest { @Test fun `returns None if multiNetworkStatusSupplier returns empty list`() = runTest { // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns emptySet() @@ -143,7 +143,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } } @@ -156,7 +156,7 @@ class GetAccountCurrencyByAddressUseCaseTest { value = NetworkStatus.Unreachable(address = null), ) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -168,7 +168,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } } @@ -183,7 +183,7 @@ class GetAccountCurrencyByAddressUseCaseTest { value = NetworkStatus.Unreachable(address = validNetworkAddress), ) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -200,7 +200,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), @@ -220,7 +220,7 @@ class GetAccountCurrencyByAddressUseCaseTest { ) val accountList = AccountList.empty(userWalletId) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -237,7 +237,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), @@ -255,7 +255,7 @@ class GetAccountCurrencyByAddressUseCaseTest { ) val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = setOf(currency)) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -273,7 +273,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertSome(actual, expected) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt index 7adf3a3264..2105cce1ee 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt @@ -79,7 +79,7 @@ class GetAccountCurrencyStatusUseCaseTest { ) val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(accountStatus) + every { this@mockk.accountStatuses } returns listOf(accountStatus) } coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList @@ -117,7 +117,7 @@ class GetAccountCurrencyStatusUseCaseTest { ) val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + every { this@mockk.accountStatuses } returns listOf(mainAccountStatus, accountStatus, mockk()) } coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList @@ -154,7 +154,7 @@ class GetAccountCurrencyStatusUseCaseTest { ) val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(accountStatus) + every { this@mockk.accountStatuses } returns listOf(accountStatus) } coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList @@ -198,7 +198,7 @@ class GetAccountCurrencyStatusUseCaseTest { ) val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(accountStatus) + every { this@mockk.accountStatuses } returns listOf(accountStatus) } coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) @@ -237,7 +237,7 @@ class GetAccountCurrencyStatusUseCaseTest { ) val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + every { this@mockk.accountStatuses } returns listOf(mainAccountStatus, accountStatus, mockk()) } coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) @@ -271,7 +271,7 @@ class GetAccountCurrencyStatusUseCaseTest { ) val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(accountStatus) + every { this@mockk.accountStatuses } returns listOf(accountStatus) } coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) 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/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountNameIndexerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountNameIndexerTest.kt new file mode 100644 index 0000000000..13a9b27103 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/AccountNameIndexerTest.kt @@ -0,0 +1,43 @@ +package com.tangem.domain.account.status.utils + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountNameIndexerTest { + + @ParameterizedTest + @ProvideTestModels + fun transform(model: TestModel) { + // Act + val actual = AccountNameIndexer.transform(model.input) + + // Assert + Truth.assertThat(actual).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + TestModel(input = "Bitcoin", expected = "Bitcoin(1)"), + TestModel(input = "Bitcoin(5)", expected = "Bitcoin(6)"), + TestModel(input = "Ethereum(123)", expected = "Ethereum(124)"), + TestModel(input = "Account(0)", expected = "Account(1)"), + TestModel(input = "Bitcoin(old)new", expected = "Bitcoin(old)new(1)"), + TestModel(input = "Bitcoin(10)new", expected = "Bitcoin(10)new(1)"), + TestModel(input = "", expected = "(1)"), + TestModel(input = "(42)", expected = "(43)"), + TestModel(input = "Account(999999)", expected = "Account(1000000)"), + TestModel(input = "Wallet(9)", expected = "Wallet(10)"), + TestModel(input = "My Account (5)", expected = "My Account (6)"), + TestModel(input = "Bitcoin💰(2)", expected = "Bitcoin💰(3)"), + TestModel(input = "Bitcoin(abc)", expected = "Bitcoin(abc)(1)"), + TestModel(input = "Bitcoin()", expected = "Bitcoin()(1)"), + TestModel(input = "Bitcoin(", expected = "Bitcoin((1)"), + TestModel(input = "Bitcoin)", expected = "Bitcoin)(1)"), + TestModel(input = "EthereumEthereumEthereum(999)", expected = "EthereumEthere(1000)"), + TestModel(input = "a".repeat(100), expected = "a".repeat(17) + "(1)"), + ) + + data class TestModel(val input: String, val expected: String) +} \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/BackupValidator.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/BackupValidator.kt new file mode 100644 index 0000000000..116b0060b5 --- /dev/null +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/BackupValidator.kt @@ -0,0 +1,46 @@ +package com.tangem.domain.card + +import com.tangem.common.card.EllipticCurve +import com.tangem.domain.card.configs.CardConfig +import com.tangem.domain.models.scan.CardDTO + +object BackupValidator { + + fun isValidFull(cardDTO: CardDTO): Boolean { + return validateBackupStatus(cardDTO) && validateCurves(cardDTO) + } + + fun isValidBackupStatus(cardDTO: CardDTO): Boolean { + return validateBackupStatus(cardDTO) + } + + private fun validateCurves(cardDTO: CardDTO): Boolean { + val config = CardConfig.createConfig(cardDTO) + // / Since the curve `bls12381_G2_AUG` was added later into first generation of wallets, + // we cannot determine whether this curve is missing due to an error or because the user + // did not want to recreate the wallet. + val expectedCurves = config.mandatoryCurves + .filterNot { it == EllipticCurve.Bls12381G2Aug } + val curves = cardDTO.wallets.map { it.curve } + for (expectedCurve in expectedCurves) { + val cardCurvesCount = curves.count { it == expectedCurve } + + // missing curve + if (cardCurvesCount == 0) { + return false + } + + // duplicated curve + if (cardCurvesCount > 1) { + return false + } + } + return true + } + + private fun validateBackupStatus(cardDTO: CardDTO): Boolean { + val backupStatus = cardDTO.backupStatus + backupStatus ?: return true // for card with null backup status, validation should always returns true + return backupStatus !is CardDTO.BackupStatus.CardLinked + } +} \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt index 101ec1b9a4..fe41ac4f05 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt @@ -3,6 +3,7 @@ package com.tangem.domain.card.common.visa import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.derivation.DerivationStyle import com.tangem.common.card.FirmwareVersion +import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.models.scan.CardDTO private const val VISA_BATCH_START = "AE" @@ -16,6 +17,7 @@ object VisaUtilities { val visaDefaultDerivationPath get() = visaBlockchain.derivationPath(DerivationStyle.V3) + val customDerivationPath = DerivationPath("m/44'/60'/999999'/0/0") fun visaDefaultDerivationPath(style: DerivationStyle) = visaBlockchain.derivationPath(style) 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/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt index 9ea3f6e4db..3adab018df 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt @@ -10,11 +10,6 @@ object LogConfig { } object NetworkLogConfig { - const val mercuryoService: Boolean = false - const val moonPayService: Boolean = false - const val utorgService: Boolean = false - val tangemTechService: Boolean = BuildConfig.LOG_ENABLED - val paymentologyApiService: Boolean = BuildConfig.LOG_ENABLED val blockchainSdkNetwork: Boolean = BuildConfig.LOG_ENABLED } 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 e6ac3a721d..34e368afbc 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@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..4cdd14d937 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, ): 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 c5ef6b9cd7..70da7009cd 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 @@ -25,6 +25,7 @@ import kotlinx.coroutines.withContext * [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/account/AccountName.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt index ead33f51a3..0a5433424d 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountName.kt @@ -74,7 +74,8 @@ sealed interface AccountName { companion object { - private const val MAX_LENGTH = 20 + /** Maximum allowed length for an account name */ + const val MAX_LENGTH = 20 /** * Factory method to create an [AccountName] instance. diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt index 0d99a1abb2..c417f19085 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountStatus.kt @@ -17,6 +17,10 @@ sealed interface AccountStatus { /** The account associated with this status */ val account: Account + /** Unique identifier of the account */ + val accountId: AccountId + get() = account.accountId + /** * Represents the status of a crypto portfolio account * 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/nft/build.gradle.kts b/domain/nft/build.gradle.kts index 6752c4736d..e7b3973752 100644 --- a/domain/nft/build.gradle.kts +++ b/domain/nft/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { // region Project – Domain implementation(projects.domain.core) + implementation(projects.domain.account.status) implementation(projects.domain.models) implementation(projects.domain.networks) implementation(projects.domain.nft.models) diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/WalletNFTCollections.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/WalletNFTCollections.kt new file mode 100644 index 0000000000..f6526e3509 --- /dev/null +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/WalletNFTCollections.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.nft.models + +import com.tangem.domain.models.account.Account + +data class WalletNFTCollections( + val collections: Map>, +) { + val flattenCollections by lazy { collections.values.flatten() } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt index 954646a953..b79f69823d 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt @@ -1,25 +1,54 @@ package com.tangem.domain.nft +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +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.wallet.UserWalletId import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.WalletNFTCollections import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.* class GetNFTCollectionsUseCase( private val currenciesRepository: CurrenciesRepository, private val nftRepository: NFTRepository, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val accountsFeatureToggles: AccountsFeatureToggles, ) { @OptIn(ExperimentalCoroutinesApi::class) - operator fun invoke(userWalletId: UserWalletId): Flow> = currenciesRepository - .getWalletCurrenciesUpdates(userWalletId) - .flatMapLatest { - val networks = it - .map { cryptoCurrency -> cryptoCurrency.network } - .distinct() - nftRepository.observeCollections(userWalletId, networks) + operator fun invoke(userWalletId: UserWalletId): Flow> = + if (accountsFeatureToggles.isFeatureEnabled) { + invokeForAccounts(userWalletId).map { it.flattenCollections } + } else { + currenciesRepository + .getWalletCurrenciesUpdates(userWalletId) + .flatMapLatest { + nftCollections(userWalletId, it) + } } + + fun invokeForAccounts(userWalletId: UserWalletId): Flow { + fun AccountStatus.flowOfNFTCollections(): Flow>> = + nftCollections(userWalletId, this.flattenCurrencies().map { it.currency }) + .map { nfts -> this.account to nfts } + + return singleAccountStatusListSupplier(userWalletId) + .mapLatest { statusList -> statusList.accountStatuses.map { it.flowOfNFTCollections() } } + .flatMapLatest { flows -> combine(flows) { WalletNFTCollections(it.toMap()) } } + } + + private fun nftCollections( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Flow> { + val networks = cryptoCurrencies + .map { cryptoCurrency -> cryptoCurrency.network } + .distinct() + return nftRepository.observeCollections(userWalletId, networks) + } } \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt index 74a6eea33e..e3f8d2c71e 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworksUseCase.kt @@ -1,32 +1,48 @@ package com.tangem.domain.nft +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.models.PortfolioId +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.models.NFTNetworks import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull class GetNFTNetworksUseCase( private val currenciesRepository: CurrenciesRepository, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val nftRepository: NFTRepository, ) { - operator fun invoke(userWalletId: UserWalletId): Flow = currenciesRepository - .getWalletCurrenciesUpdates(userWalletId) - .map { cryptoCurrencies -> - val availableNetworks = cryptoCurrencies - .map { cryptoCurrency -> cryptoCurrency.network } - .filter { nftRepository.isNFTSupported(userWalletId, it) } - .sortedBy { it.name } + operator fun invoke(portfolioId: PortfolioId): Flow = when (portfolioId) { + is PortfolioId.Account -> singleAccountStatusListSupplier(portfolioId.userWalletId) + .map { it.accountStatuses } + .mapNotNull { accountStatuses -> accountStatuses.find { it.account.accountId == portfolioId.accountId } } + .map { accountStatus -> accountStatus.flattenCurrencies().map { it.currency } } + .mapLatest { it.toNFTNetworks(portfolioId.userWalletId) } + is PortfolioId.Wallet -> + currenciesRepository + .getWalletCurrenciesUpdates(portfolioId.userWalletId) + .map { cryptoCurrencies -> cryptoCurrencies.toNFTNetworks(portfolioId.userWalletId) } + } - val unavailableNetworks = nftRepository - .getNFTSupportedNetworks(userWalletId) - .filter { supportedNetwork -> availableNetworks.none { it.id == supportedNetwork.id } } - .sortedBy { it.name } + private suspend fun List.toNFTNetworks(userWalletId: UserWalletId): NFTNetworks { + val availableNetworks = this + .map { cryptoCurrency -> cryptoCurrency.network } + .filter { nftRepository.isNFTSupported(userWalletId, it) } + .sortedBy { it.name } - NFTNetworks( - availableNetworks = availableNetworks, - unavailableNetworks = unavailableNetworks, - ) - } + val unavailableNetworks = nftRepository + .getNFTSupportedNetworks(userWalletId) + .filter { supportedNetwork -> availableNetworks.none { it.id == supportedNetwork.id } } + .sortedBy { it.name } + + return NFTNetworks( + availableNetworks = availableNetworks, + unavailableNetworks = unavailableNetworks, + ) + } } \ 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..06bf7442f2 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 @@ -5,7 +5,6 @@ import java.math.BigDecimal data class OnrampOffersBlock( val category: OnrampOfferCategory, val offers: List, - val hasMoreOffers: Boolean, ) data class OnrampOffer( @@ -15,7 +14,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..14173b152f 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,15 @@ 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 + sealed interface Unavailable : PaymentMethodStatus { + val requiredAmount: BigDecimal + + data class MinAmount(override val requiredAmount: BigDecimal) : Unavailable + data class MaxAmount(override val requiredAmount: BigDecimal) : Unavailable } } \ 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..970018eeee 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,76 @@ 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 amountErrorQuotes = methodQuotes.filterIsInstance() + val minAmountErrors = amountErrorQuotes.filter { it.error is OnrampError.AmountError.TooSmallError } + + if (minAmountErrors.isNotEmpty()) { + val minRequiredAmount = minAmountErrors.minOf { it.error.requiredAmount } + PaymentMethodStatus.Unavailable.MinAmount(minRequiredAmount) + } else { + val maxAmount = amountErrorQuotes.maxOfOrNull { it.error.requiredAmount } ?: BigDecimal.ZERO + PaymentMethodStatus.Unavailable.MaxAmount(maxAmount) + } + } } } \ 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..1fc0a6cf02 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 @@ -3,8 +3,6 @@ package com.tangem.domain.onramp import arrow.core.left 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.* import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.onramp.model.error.OnrampError @@ -25,13 +23,10 @@ class GetOnrampOffersUseCase( private val settingsRepository: SettingsRepository, ) { - operator fun invoke( - userWalletId: UserWalletId, - cryptoCurrencyId: CryptoCurrency.ID, - ): EitherFlow> { + operator fun invoke(): EitherFlow> { return combine( onrampRepository.getQuotes(), - onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId), + onrampTransactionRepository.getAllTransactions(), ) { quotes, transactions -> processOffers(quotes, transactions) } @@ -47,7 +42,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 -> @@ -63,7 +63,6 @@ class GetOnrampOffersUseCase( recentOffer = recentOffer, bestRateOffer = bestRateOffer, fastestOffer = fastestOffer, - allOffers = offers, ) } @@ -71,8 +70,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 +82,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 +98,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,11 +110,34 @@ 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?, fastestOffer: OnrampOffer?, - allOffers: List, ): List { val recommendedOffers = buildRecommendedOffers( recentOffer = recentOffer, @@ -120,9 +145,6 @@ class GetOnrampOffersUseCase( fastestOffer = fastestOffer, ) - val shownOffersCount = (if (recentOffer != null) 1 else 0) + recommendedOffers.size - val hasMoreOffers = allOffers.size > shownOffersCount - return buildList { if (recentOffer != null) { add( @@ -138,17 +160,15 @@ class GetOnrampOffersUseCase( rateDif = if (bestRateOffer != null) recentOffer.rateDif else null, ), ), - hasMoreOffers = false, ), ) } - if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) { + if (recommendedOffers.isNotEmpty()) { add( OnrampOffersBlock( category = OnrampOfferCategory.Recommended, offers = recommendedOffers, - hasMoreOffers = hasMoreOffers, ), ) } @@ -161,10 +181,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 @@ -179,10 +199,10 @@ class GetOnrampOffersUseCase( ): List { return buildList { if (isSameOffer(bestRateOffer, fastestOffer)) { - bestRateOffer?.let { offer -> + if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) { add( - offer.copy( - advantages = OnrampOfferAdvantages.BestRate, + bestRateOffer.copy( + advantages = OnrampOfferAdvantages.GreatRate, rateDif = null, ), ) @@ -191,7 +211,7 @@ class GetOnrampOffersUseCase( if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) { add( bestRateOffer.copy( - advantages = OnrampOfferAdvantages.BestRate, + advantages = OnrampOfferAdvantages.GreatRate, rateDif = null, ), ) @@ -211,15 +231,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/analytics/OnrampAnalyticsEvent.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt index 7dd1e1a772..b7da9150d6 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/analytics/OnrampAnalyticsEvent.kt @@ -190,6 +190,19 @@ sealed class OnrampAnalyticsEvent( ), ) + data class RecentlyBuyClicked( + private val tokenSymbol: String, + private val providerName: String, + private val paymentMethod: String, + ) : OnrampAnalyticsEvent( + event = "Recently Used Clicked", + params = mapOf( + TOKEN_PARAM to tokenSymbol, + PROVIDER to providerName, + PAYMENT_METHOD to paymentMethod, + ), + ) + data object AllOffersClicked : OnrampAnalyticsEvent( event = "Button - All Offers", params = emptyMap(), diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampTransactionRepository.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampTransactionRepository.kt index 64dbac7fcc..c0ab3000ce 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampTransactionRepository.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/OnrampTransactionRepository.kt @@ -12,6 +12,8 @@ interface OnrampTransactionRepository { suspend fun getTransactionById(txId: String): OnrampTransaction? + fun getAllTransactions(): Flow> + fun getTransactions(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Flow> suspend fun updateTransactionStatus( 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..0216cbe33c 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.0001")) 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..c6b773442b 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.MinAmount(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,47 @@ 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"), + isMinAmountError: Boolean = true, + ): 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 if (isMinAmountError) { + OnrampError.AmountError.TooSmallError(requiredAmount = requiredAmount) + } else { + OnrampError.AmountError.TooBigError(requiredAmount = 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..660735a8e7 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 @@ -2,7 +2,6 @@ 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.* import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.onramp.repositories.OnrampErrorResolver @@ -25,7 +24,6 @@ class GetOnrampOffersUseCaseTest { private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true) private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true) private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true) - private val userWalletId: UserWalletId = mockk(relaxUnitFun = true) private lateinit var useCase: GetOnrampOffersUseCase @@ -47,11 +45,11 @@ class GetOnrampOffersUseCaseTest { coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes) - coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf( emptyTransactions, ) - val result = useCase(userWalletId, cryptoCurrencyId) + val result = useCase() result.collect { either -> Truth.assertThat(either.isRight()).isTrue() @@ -62,13 +60,13 @@ class GetOnrampOffersUseCaseTest { } coVerify { onrampRepository.getQuotes() } - coVerify { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } + coVerify { onrampTransactionRepository.getAllTransactions() } } @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,16 +76,16 @@ class GetOnrampOffersUseCaseTest { ) val transactions = listOf( - createMockTransaction("provider1", "card", 1000L), + createMockTransaction("Provider 1", "Card", 1000L), ) coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) - coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf( transactions, ) - val result = useCase(userWalletId, cryptoCurrencyId) + val result = useCase() result.collect { either -> Truth.assertThat(either.isRight()).isTrue() @@ -105,7 +103,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 +111,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") @@ -128,11 +126,11 @@ class GetOnrampOffersUseCaseTest { coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) - coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf( transactions, ) - val result = useCase(userWalletId, cryptoCurrencyId) + val result = useCase() result.collect { either -> Truth.assertThat(either.isRight()).isTrue() @@ -145,10 +143,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 +157,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") @@ -173,11 +173,11 @@ class GetOnrampOffersUseCaseTest { coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) - coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf( transactions, ) - val result = useCase(userWalletId, cryptoCurrencyId) + val result = useCase() result.collect { either -> Truth.assertThat(either.isRight()).isTrue() @@ -190,17 +190,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 +215,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( @@ -227,33 +227,33 @@ class GetOnrampOffersUseCaseTest { coEvery { settingsRepository.isGooglePayAvailability() } returns false coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) - coEvery { onrampTransactionRepository.getTransactions(userWalletId, cryptoCurrencyId) } returns flowOf( + coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf( transactions, ) - val result = useCase(userWalletId, cryptoCurrencyId) + val result = useCase() result.collect { either -> Truth.assertThat(either.isRight()).isTrue() 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 +278,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/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt index 7bd7a99414..89edac21b3 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt @@ -1,6 +1,7 @@ package com.tangem.domain.swap.models import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal @@ -13,6 +14,8 @@ data class SwapTransactionListModel( val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, val toCryptoCurrency: CryptoCurrency, + val fromAccount: Account.CryptoPortfolio?, + val toAccount: Account.CryptoPortfolio?, val transactions: List, ) diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt index 5f9a18e411..7fe9292ea2 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.swap +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.wallet.UserWallet @@ -20,12 +21,17 @@ interface SwapTransactionRepository { * @param userWalletId selected user wallet id * @param fromCryptoCurrency currency swap from * @param toCryptoCurrency currency swap to + * @param fromAccount account swap from + * @param toAccount account swap to * @param transaction swap transaction */ + @Suppress("LongParameterList") suspend fun storeTransaction( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transaction: SwapTransactionModel, ) diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt index 8d6869366a..be872f8701 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt @@ -3,6 +3,7 @@ package com.tangem.domain.swap.usecase import arrow.core.Either import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.ExpressProviderType.Companion.shouldStoreSwapTransaction +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.swap.SwapErrorResolver @@ -21,6 +22,8 @@ class SwapTransactionSentUseCase( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrencyStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, swapDataTransactionModel: SwapDataTransactionModel, provider: ExpressProvider, txHash: String, @@ -32,6 +35,8 @@ class SwapTransactionSentUseCase( userWalletId = userWallet.walletId, fromCryptoCurrency = fromCryptoCurrencyStatus.currency, toCryptoCurrency = toCryptoCurrencyStatus.currency, + fromAccount = fromAccount, + toAccount = toAccount, transaction = SwapTransactionModel( txId = swapDataTransactionModel.txId, provider = provider, 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/error/TokenListSortingError.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListSortingError.kt index 847432134b..eb03e4aa5e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListSortingError.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListSortingError.kt @@ -8,5 +8,7 @@ sealed class TokenListSortingError { object UnableToSortTokenList : TokenListSortingError() + data class UnableToSortAccounts(val accountIds: List) : TokenListSortingError() + data class DataError(val cause: Throwable) : TokenListSortingError() } \ 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/TangemPayInitialCredentials.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayInitialCredentials.kt new file mode 100644 index 0000000000..40a1132513 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayInitialCredentials.kt @@ -0,0 +1,3 @@ +package com.tangem.domain.visa.model + +data class TangemPayInitialCredentials(val customerWalletAddress: String, val authTokens: VisaAuthTokens) \ No newline at end of file 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..4f8798a4f0 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,33 @@ 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() + + @Serializable + data class Collateral( + override val id: String, + override val jsonRepresentation: String, + override val date: SerializedDateTime, + override val amount: SerializedBigDecimal, + override val currency: SerializedCurrency, + val transactionHash: String, ) : TangemPayTxHistoryItem() enum class Status { diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/DataForReceiveFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/DataForReceiveFactory.kt deleted file mode 100644 index 1b78f76580..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/DataForReceiveFactory.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.domain.pay - -import arrow.core.Either -import com.tangem.core.error.UniversalError -import com.tangem.domain.models.ReceiveAddressModel -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId - -interface DataForReceiveFactory { - - fun getDataForReceive(depositAddress: String, chainId: Int): Either -} - -data class DataForReceive( - val walletId: UserWalletId, - val currency: CryptoCurrency, - val receiveAddress: List, -) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayTopUpDataFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayTopUpDataFactory.kt new file mode 100644 index 0000000000..887df24ef0 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayTopUpDataFactory.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.pay + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal + +interface TangemPayTopUpDataFactory { + + fun create( + depositAddress: String, + chainId: Int, + cryptoBalance: BigDecimal, + fiatBalance: BigDecimal, + ): Either +} + +data class TangemPayTopUpData( + val walletId: UserWalletId, + val currency: CryptoCurrency, + val cryptoBalance: BigDecimal, + val fiatBalance: BigDecimal, + val depositAddress: String, + val receiveAddress: List, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt index 05725f0183..69ec635f4a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -1,11 +1,12 @@ package com.tangem.domain.pay.datasource import arrow.core.Either +import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.VisaAuthTokens interface TangemPayAuthDataSource { - suspend fun generateNewAuthTokens(address: String, cardId: String): Either + suspend fun produceInitialCredentials(cardId: String): Either suspend fun refreshAuthTokens(refreshToken: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt new file mode 100644 index 0000000000..7631289549 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.pay.model + +enum class SetPinResult { + SUCCESS, PIN_TOO_WEAK, DECRYPTION_ERROR, UNKNOWN_ERROR +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt index 39b0e8edfa..426b1814a8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt @@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails @@ -10,4 +11,10 @@ interface CardDetailsRepository { suspend fun getCardBalance(): Either suspend fun revealCardDetails(): Either + + suspend fun setPin(pin: String): Either + + suspend fun isAddToWalletDone(): Either + + suspend fun setAddToWalletAsDone(): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index 5c93a97e19..88d98ab106 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -9,6 +9,10 @@ interface OnboardingRepository { suspend fun validateDeeplink(link: String): Either + suspend fun isTangemPayInitialDataProduced(): Boolean + + suspend fun produceInitialData() + suspend fun getCustomerInfo(): Either suspend fun createOrder(): Either diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ProduceTangemPayInitialDataUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ProduceTangemPayInitialDataUseCase.kt new file mode 100644 index 0000000000..bddeb01eaf --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ProduceTangemPayInitialDataUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.domain.pay.repository.OnboardingRepository + +class ProduceTangemPayInitialDataUseCase( + private val repository: OnboardingRepository, +) { + + suspend operator fun invoke(): Either { + return catch { + val isDataProduced = repository.isTangemPayInitialDataProduced() + if (isDataProduced) { + return@catch Unit + } else { + repository.produceInitialData() + } + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/GetTangemPayCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/GetTangemPayCurrencyStatusUseCase.kt new file mode 100644 index 0000000000..a6f4b0baf1 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/GetTangemPayCurrencyStatusUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.tangempay + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import java.math.BigDecimal + +interface GetTangemPayCurrencyStatusUseCase { + + suspend operator fun invoke( + currency: CryptoCurrency, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + depositAddress: String, + ): CryptoCurrencyStatus? +} \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt index 7931afed92..49ae45531a 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt @@ -1,12 +1,14 @@ package com.tangem.domain.walletconnect.model import com.domain.blockaid.models.dapp.CheckDAppResult +import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.Network import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession import com.tangem.domain.models.wallet.UserWallet data class WcSession( val wallet: UserWallet, + val account: Account?, val networks: Set, val sdkModel: WcSdkSession, val securityStatus: CheckDAppResult, diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt index 1122347614..e55e69f940 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt @@ -1,9 +1,11 @@ package com.tangem.domain.walletconnect.model +import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet data class WcSessionApprove( val wallet: UserWallet, + val account: Account?, val network: List, ) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt index 15ccc11fc1..eaee191c92 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt @@ -2,12 +2,14 @@ package com.tangem.domain.walletconnect.model import com.domain.blockaid.models.dapp.CheckDAppResult import com.squareup.moshi.JsonClass +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId @JsonClass(generateAdapter = true) data class WcSessionDTO( val topic: String, val walletId: UserWalletId, + val accountId: AccountId? = null, val url: String?, val securityStatus: CheckDAppResult = CheckDAppResult.FAILED_TO_VERIFY, val connectingTime: Long? = null, diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt index c6d611f166..dbec25e6d2 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt @@ -1,18 +1,22 @@ package com.tangem.domain.walletconnect.model import com.domain.blockaid.models.dapp.CheckDAppResult +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.network.Network -import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData data class WcSessionProposal( val dAppMetaData: WcAppMetaData, val proposalNetwork: Map, + val proposalAccountNetwork: Map?, val securityStatus: CheckDAppResult, ) { data class ProposalNetwork( val wallet: UserWallet, + val account: Account?, val missingRequired: Set, val required: Set, val available: Set, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index 7513e6e3bf..a10a8c5492 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -1,9 +1,9 @@ package com.tangem.domain.wallets.repository import arrow.core.Either -import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.models.UserWalletRemoteInfo import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import kotlinx.coroutines.flow.Flow @@ -45,6 +45,8 @@ interface WalletsRepository { suspend fun markWallet2WasCreated(userWalletId: UserWalletId) + suspend fun createWallet(userWalletId: UserWalletId) + fun nftEnabledStatus(userWalletId: UserWalletId): Flow fun nftEnabledStatuses(): Flow> diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 17e942ca63..1a0bdccf7e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -30,7 +30,7 @@ platform :android do FileUtils.cp("../app/src/main/assets/tangem-app-config/android/google-services/dev/google-services.json", "../app") FileUtils.cp("../tangem-android-tools/CI/gradle_properties/tests_ci_gradle.properties", "../gradle.properties") puts File.read("../gradle.properties") - gradle(task: "testGoogleDebugUnitTest testDebugUnitTest --continue") + gradle(task: "test") end desc "Build internal APK Firebase App Distribution" 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,15 +143,21 @@ 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 } - val walletTitle = PortfolioSelectorItemUM.GroupTitle( - id = "GroupTitle ${wallet.walletId.stringValue}", - name = stringReference(wallet.name), - ) - add(walletTitle) + when (balanceFetcher.mode.value) { + // for Wallet mode expected single portfolioData.balances + is PortfolioFetcher.Mode.Wallet -> Unit + is PortfolioFetcher.Mode.All -> { + val walletTitle = PortfolioSelectorItemUM.GroupTitle( + id = "GroupTitle ${wallet.walletId.stringValue}", + name = stringReference(wallet.name), + ) + add(walletTitle) + } + } portfolio.accountsBalance.accountStatuses.forEach { accountStatus -> val isEnabledByFeature = isEnabled(wallet, accountStatus) @@ -156,7 +172,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 +186,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/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt index faf365c0f3..5acce0f6a4 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt @@ -135,13 +135,15 @@ internal class AskBiometryModel @Inject constructor( params.modelCallbacks.onAllowed() } - private fun setBiometryLockForAllWallets() { - modelScope.launch { - userWalletsListRepository.userWalletsSync().forEach { userWallet -> - userWalletsListRepository.setLock( - userWalletId = userWallet.walletId, - lockMethod = UserWalletsListRepository.LockMethod.Biometric, - changeUnsecured = false, + private suspend fun setBiometryLockForAllWallets() { + userWalletsListRepository.userWalletsSync().forEach { userWallet -> + userWalletsListRepository.setLock( + userWalletId = userWallet.walletId, + lockMethod = UserWalletsListRepository.LockMethod.Biometric, + changeUnsecured = false, + ).onLeft { + uiMessageSender.send( + SnackbarMessage(stringReference("Something went wrong. Please contact support: $it")), ) } } 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/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 481078432a..0a0ed7aef6 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { /** Api */ implementation(projects.features.hotWallet.api) + implementation(projects.features.onboardingV2.api) implementation(projects.features.pushNotifications.api) /** Core modules */ 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/DefaultUpgradeWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt index 2f0e91d1de..3775021494 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt @@ -5,21 +5,39 @@ 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.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.hotwallet.UpgradeWalletComponent import com.tangem.features.hotwallet.upgradewallet.ui.UpgradeWalletContent +import com.tangem.features.onboarding.v2.util.ResetCardsComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach @Suppress("UnusedPrivateMember") internal class DefaultUpgradeWalletComponent @AssistedInject constructor( @Assisted private val context: AppComponentContext, @Assisted private val params: UpgradeWalletComponent.Params, + resetCardsComponentFactory: ResetCardsComponent.Factory, ) : UpgradeWalletComponent, AppComponentContext by context { private val model: UpgradeWalletModel = getOrCreateModel(params) + private val resetCardsComponent = resetCardsComponentFactory.create( + context = child("ResetCardsComponent"), + params = ResetCardsComponent.Params( + callbacks = model.resetCardsComponentCallbacks, + ), + ) + + init { + model.startResetCardsFlow + .onEach { resetCardsComponent.startResetCardsFlow(it) } + .launchIn(componentScope) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() 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..6939c1daf0 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 @@ -16,20 +16,25 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.toWrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.card.BackupValidator import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.features.hotwallet.UpgradeWalletComponent import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM +import com.tangem.features.onboarding.v2.util.ResetCardsComponent import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.extensions.localizedDescriptionRes import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -50,17 +55,21 @@ internal class UpgradeWalletModel @Inject constructor( private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, private val tangemSdkManager: TangemSdkManager, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, ) : Model() { private val params = paramsContainer.require() - private val _uiState = MutableStateFlow( - UpgradeWalletUM( - onBackClick = { router.pop() }, - onBuyTangemWalletClick = ::onBuyTangemWalletClick, - onScanDeviceClick = ::onScanDeviceClick, - ), - ) - internal val uiState: StateFlow = _uiState + val resetCardsComponentCallbacks = ResetCardsModelCallbacks() + val startResetCardsFlow = MutableSharedFlow() + + internal val uiState: StateFlow + field = MutableStateFlow( + UpgradeWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onContinueClick = ::onContinueClick, + ), + ) override fun onDestroy() { clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) @@ -73,7 +82,7 @@ internal class UpgradeWalletModel @Inject constructor( } } - private fun onScanDeviceClick() { + private fun onContinueClick() { scanCard() } @@ -89,6 +98,13 @@ internal class UpgradeWalletModel @Inject constructor( tangemSdkManager .scanProduct() .doOnSuccess { + // Check if user attempted to upgrade before but something went wrong and a full reset is required + val userWallet = coldUserWalletBuilderFactory.create(it).build() + if (userWallet?.walletId == params.userWalletId && BackupValidator.isValidFull(it.card).not()) { + startResetCardsFlow.emit(userWallet) + return@doOnSuccess + } + delay(DELAY_SDK_DIALOG_CLOSE) tangemSdkManager.changeDisplayedCardIdNumbersCount(it) navigateToUpgradeFlow(it) @@ -100,7 +116,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) { @@ -143,4 +159,14 @@ internal class UpgradeWalletModel @Inject constructor( ), ) } + + inner class ResetCardsModelCallbacks : ResetCardsComponent.ModelCallbacks { + override fun onCancel() { + setLoading(false) + } + + override fun onComplete() { + setLoading(false) + } + } } \ No newline at end of file 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..557fd53518 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, @@ -19,11 +20,12 @@ internal data class AvailableToAddData( internal data class AvailableToAddWallet( val userWallet: UserWallet, - val accounts: Set, + val accounts: List, val availableNetworks: Set, 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/converter/AvailableToAddDataConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt index 132a4b8ba2..34cf4cd075 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -5,6 +5,7 @@ import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase import com.tangem.domain.markets.GetTokenMarketCryptoCurrency import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency @@ -28,11 +29,19 @@ internal class AvailableToAddDataConverter @Inject constructor( marketParams: TokenMarketParams, ): AvailableToAddData { suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount { - val addedNetworks = availableNetworks - .mapNotNull { createCryptoCurrency(wallet, it, marketParams) } - .mapNotNull { getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, it) } - .mapNotNull { it.getOrNull()?.status?.currency?.network } - .toSet() + val currencies = availableNetworks + .mapNotNull { createCryptoCurrency(wallet, it, marketParams, this.account) } + + val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies) + .fold( + ifEmpty = { emptySet() }, + ifSome = { map -> + map.values.flatMapTo(hashSetOf()) { statuses -> + statuses.map { it.currency.network } + } + }, + ) + return AvailableToAddAccount( account = this, availableNetworks = availableNetworks, @@ -60,7 +69,7 @@ internal class AvailableToAddDataConverter @Inject constructor( val availableToAddWallets: Map = balances .map { - val (wallet, balance) = it + val (wallet, _) = it val availableToAddWallet = getAvailableToAddWallet(it) wallet.walletId to availableToAddWallet } @@ -82,9 +91,16 @@ internal class AvailableToAddDataConverter @Inject constructor( userWallet: UserWallet, network: TokenMarketInfo.Network, marketParams: TokenMarketParams, - ): CryptoCurrency? = getTokenMarketCryptoCurrency( - userWalletId = userWallet.walletId, - tokenMarketParams = marketParams, - network = network, - ) + account: Account, + ): CryptoCurrency? { + val derivationIndex = when (account) { + is Account.CryptoPortfolio -> account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = marketParams, + network = network, + accountIndex = derivationIndex, + ) + } } \ No newline at end of file diff --git a/features/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..548977f967 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -0,0 +1,358 @@ +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.features.markets.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val TOKEN_ACTIONS_DELAY = 500L + +@ModelScoped +@Suppress("LongParameterList") +internal class AddToPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val callbackDelegate: AddToPortfolioCallbackDelegate, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val messageSender: UiMessageSender, + 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 [AddToPortfolioManager.state] + if (!data.availableToAdd) { + finishFlow() + return@channelFlow + } + + // init data flows, emits on user/code selection, updates state holder + val firstSelectedPortfolio = setupPortfolioFlow(data) + .onEach { selectedPortfolio.emit(it) } + val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio) + .onEach { selectedNetwork.emit(it) } + + val isSinglePortfolio = data.isSinglePortfolio + if (isSinglePortfolio) { + val accountId = data.availableToAddWallets.values.first() + .availableToAddAccounts.values.first() + .account.account.accountId + // force select a portfolio, triggers [selectedPortfolio] + portfolioSelectorController.selectAccount(accountId) + } else { + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) + } + + val firstPartOfNavigation: Job = firstSelectedPortfolio + .onEach { portfolio -> + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + when { + // force select a network, triggers [selectedNetwork] + isSingleAvailableNetwork -> { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // it's important to control root screen, UI depends on it(close/arrow icon) + isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) + else -> navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + .launchIn(this) + + // main flow that combine all require data + val allRequireForAdd = combine( + flow = firstSelectedNetwork, + flow2 = firstSelectedPortfolio, + transform = { a, b -> a to b }, + ) + + // suspend until all required data is selected + allRequireForAdd.first() + // line of navigation to AddToken screen is finished; cancel the job, select a new root screen + firstPartOfNavigation.cancel() + navigation.replaceAll(AddToPortfolioRoutes.AddToken) + + var middleNavigationJob: Job? = null + // handle actions from AddToken screen + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changeNetworkNavigationFlow() + .launchIn(this) + val route = routeToNetworkSelector(selectedPortfolio.first()) + navigation.pushNew(route) + } + .launchIn(this) + // handle actions from AddToken screen + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this) + navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) + } + .launchIn(this) + + // suspend until token is added + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + middleNavigationJob?.cancel() + val selectedPortfolio = selectedPortfolio.first() + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + setupTokenActionsFlow(selectedPortfolio, addedToken) + .onEach { + tokenActionsData.emit(it) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishFlow() } + .launchIn(this) + + callbackDelegate.onLaterClick.receiveAsFlow().first() + finishFlow() + } + .catch { + Timber.e(it) + params.callback.onDismiss() + } + .launchIn(modelScope) + } + + private fun changeNetworkNavigationFlow(): Flow { + return setupNetworkFlow(selectedPortfolio) + .onEach { newNetwork -> + selectedNetwork.emit(newNetwork) + navigation.popToFirst() + } + } + + private suspend fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow { + val selectedAccount = selectedPortfolio.first().account.account.account.accountId + portfolioSelectorController.selectAccount(selectedAccount) + val changedPortfolio = setupPortfolioFlow(data) + .drop(1) + .onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) } + val changedNetwork = setupNetworkFlow(changedPortfolio) + return combine( + flow = changedPortfolio, + flow2 = changedNetwork, + transform = { newPortfolio, newNetwork -> + selectedPortfolio.tryEmit(newPortfolio) + selectedNetwork.tryEmit(newNetwork) + navigation.popToFirst() + }, + ) + } + + private fun setupTokenActionsFlow( + selectedPortfolio: SelectedPortfolio, + addedToken: CryptoCurrencyStatus, + ): Flow { + val timeFlow = channelFlow { + val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } + getCryptoCurrencyActionsUseCase( + currency = addedToken.currency, + accountId = selectedPortfolio.account.account.account.accountId, + ).onEach { state -> + val requestedQuickActions = toQuickActions(state.states) + when { + requestedQuickActions.isNotEmpty() -> { + timerJob.cancel() + send(state) + } + // wait any requestedQuickActions while timer active + timerJob.isActive -> Unit + else -> close() + } + }.collect() + } + return timeFlow.map { + 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() + + private fun setupNetworkFlow(selectedPortfolioFlow: Flow): Flow = combine( + flow = selectedPortfolioFlow, + flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), + transform = transform@{ selectedPortfolio, selectedNetwork -> + SelectedNetwork( + cryptoCurrency = createCryptoCurrency( + userWallet = selectedPortfolio.userWallet, + network = selectedNetwork, + account = selectedPortfolio.account, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + availableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + account: AvailableToAddAccount, + ): CryptoCurrency? { + val accountIndex = when (account.account) { + is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token, + network = network, + accountIndex = accountIndex, + ) + } + + private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector { + return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) + } + + private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> + when (state) { + is AddToPortfolioManager.State.AvailableToAdd -> + portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> + val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] + ?: return@isEnabled false + val availableAccount = + availableWallet.availableToAddAccounts[accountStatus.account.accountId] + return@isEnabled availableAccount != null + } + AddToPortfolioManager.State.Init, + AddToPortfolioManager.State.NothingToAdd, + -> Unit + } + } +} + +@ModelScoped +internal class AddToPortfolioCallbackDelegate @Inject constructor() : + ChooseNetworkComponent.Callbacks, + TokenActionsComponent.Callbacks, + AddTokenComponent.Callbacks { + + val onNetworkSelected = Channel() + val onLaterClick = Channel() + val onChangeNetworkClick = Channel() + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + + override fun onNetworkSelected(network: TokenMarketInfo.Network) { + onNetworkSelected.trySend(network) + } + + override fun onLaterClick() { + onLaterClick.trySend(Unit) + } + + override fun onChangeNetworkClick() { + onChangeNetworkClick.trySend(Unit) + } + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } +} \ No newline at end of file diff --git a/features/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..bc1e3ae6d6 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 @@ -4,9 +4,14 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.markets.impl.R import com.tangem.features.markets.portfolio.add.api.SelectedNetwork import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent @@ -25,10 +30,11 @@ import javax.inject.Inject internal class AddTokenModel @Inject constructor( paramsContainer: ParamsContainer, private val uiBuilder: AddTokenUiBuilder, + private val messageSender: UiMessageSender, private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, - private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, ) : Model() { @@ -66,19 +72,26 @@ 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) + .onLeft { + processError(error = it) + uiState.value = um.toggleProgress(false) + return@launch + } + val status = getAccountCurrencyStatusUseCase.invokeSync( userWalletId = accountId.userWalletId, currencyId = cryptoCurrency.id, network = cryptoCurrency.network, - ).getOrNull() ?: return@launch - params.callbacks.onTokenAdded(status.status) + ).getOrNull() + if (status == null) { + processError(error = null) + } else { + params.callbacks.onTokenAdded(status.status) + } uiState.value = um.toggleProgress(false) } @@ -89,4 +102,10 @@ internal class AddTokenModel @Inject constructor( userWalletId = selectedPortfolio.userWallet.walletId, networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null), ) + + private fun processError(error: Throwable?) { + val message = error?.message?.let { stringReference(it) } + ?: resourceReference(R.string.common_something_went_wrong) + messageSender.send(ToastMessage(message = message)) + } } \ No newline at end of file diff --git a/features/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..fd1f0fecea --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -0,0 +1,334 @@ +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongParameterList") +internal class NewMarketsPortfolioDelegate @AssistedInject constructor( + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val allAccountSupplier: MultiAccountStatusListSupplier, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getUserWalletUseCase: GetUserWalletUseCase, + isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + @Assisted private val scope: CoroutineScope, + @Assisted private val token: TokenMarketParams, + @Assisted private val tokenActionsHandler: TokenActionsHandler, + @Assisted private val buttonState: Flow, + @Assisted private val onAddClick: () -> Unit, +) { + + private val currencyRawId: CryptoCurrency.RawID = token.id + private var expandedHolder: MutableStateFlow>>? = null + + private val settingsFlow: Flow = combine( + flow = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + flow3 = isAccountsModeEnabledUseCase(), + transform = ::SettingsBox, + ).shareIn( + replay = 1, + started = SharingStarted.Eagerly, + scope = scope, + ).distinctUntilChanged() + + private val availableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + fun setTokenNetworks(networks: List) { + availableNetworks.tryEmit(networks) + } + + fun combineData(): Flow { + return availableNetworks.transformLatest { availableNetworks -> + when { + availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable) + else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged()) + } + }.distinctUntilChanged() + } + + private fun onAvailableNetworksFlow(): Flow = + portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency -> + when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) { + false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged()) + true -> when (portfolioWithCurrency.hasMultiWallets) { + true -> emitAll(addFirstTokenFlow()) + false -> emit(MyPortfolioUM.UnavailableForWallet) + } + } + } + + private fun addFirstTokenFlow(): Flow = buttonState.map { + when (it) { + AddButtonState.Loading -> MyPortfolioUM.Loading + AddButtonState.Available -> MyPortfolioUM.AddFirstToken( + onAddClick = onAddClick, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + ) + AddButtonState.Unavailable -> MyPortfolioUM.Unavailable + } + } + + 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().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..ce68f5550d 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, @@ -84,7 +102,7 @@ internal class PortfolioTokenUMConverter( ) } - private fun toQuickActions(actions: List) = buildList { + fun toQuickActions(actions: List) = buildList { actions.forEach { action -> if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { when (action) { 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/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index 438a815cc3..693be634b3 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -13,6 +13,7 @@ android { dependencies { /** Api */ + implementation(projects.features.account.api) implementation(projects.features.nft.api) implementation(projects.features.tokenRecieve.api) @@ -28,6 +29,8 @@ dependencies { implementation(projects.core.datasource) /** Domain modules */ + implementation(projects.domain.account) + implementation(projects.domain.wallets) implementation(projects.domain.appCurrency.models) implementation(projects.domain.appCurrency) implementation(projects.domain.models) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt index e7e24ded3b..fd859b1c83 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt @@ -1,10 +1,20 @@ package com.tangem.features.nft.collections.entity import androidx.annotation.DrawableRes +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.extensions.TextReference +internal sealed interface NFTCollectionItem { + val id: String +} + +internal data class NFTCollectionPortfolioUM( + override val id: String, + val title: AccountTitleUM, +) : NFTCollectionItem + internal data class NFTCollectionUM( - val id: String, + override val id: String, val name: String, @DrawableRes val networkIconId: Int, val logoUrl: String?, @@ -12,4 +22,4 @@ internal data class NFTCollectionUM( val assets: NFTCollectionAssetsListUM, val isExpanded: Boolean, val onExpandClick: () -> Unit, -) \ No newline at end of file +) : NFTCollectionItem \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt index a8287ca423..9388efcd65 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt @@ -23,7 +23,7 @@ internal sealed class NFTCollectionsUM { data class Content( val search: SearchBarUM, - val collections: ImmutableList, + val collections: ImmutableList, val warnings: ImmutableList, val onReceiveClick: () -> Unit, ) : NFTCollectionsUM() diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt index 56ebea5c6b..13ec03bca2 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.nft.collections.entity.transformer import com.tangem.domain.nft.models.NFTCollection +import com.tangem.features.nft.collections.entity.NFTCollectionUM import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM import com.tangem.features.nft.collections.entity.NFTCollectionsUM import com.tangem.utils.transformer.Transformer @@ -21,7 +22,7 @@ internal class ChangeCollectionExpandedStateTransformer( is NFTCollectionsUM.Content -> prevState.content.copy( collections = prevState.content.collections.map { val collectionId = collection.collectionIdProvider() - if (it.id == collectionId) { + if (it.id == collectionId && it is NFTCollectionUM) { if (!it.isExpanded) { onFirstExpanded() } diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index da0d617e10..431de33406 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -1,10 +1,13 @@ package com.tangem.features.nft.collections.entity.transformer +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.account.Account import com.tangem.domain.nft.models.* import com.tangem.features.nft.collections.entity.* import com.tangem.features.nft.impl.R @@ -16,6 +19,8 @@ import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class UpdateDataStateTransformer( private val nftCollections: List, + private val walletNFTCollections: WalletNFTCollections? = null, + private val isAccountMode: Boolean = false, private val onReceiveClick: () -> Unit, private val onRetryClick: () -> Unit, private val onExpandCollectionClick: (NFTCollection) -> Unit, @@ -25,7 +30,9 @@ internal class UpdateDataStateTransformer( private val collectionIdProvider: NFTCollection.() -> String, ) : Transformer { + @Suppress("CyclomaticComplexMethod") override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM { + val nftCollections = walletNFTCollections?.flattenCollections ?: this.nftCollections val hasQuery = !(prevState.content as? NFTCollectionsUM.Content)?.search?.query.isNullOrEmpty() val content = when { !hasQuery && nftCollections.allCollectionsFailed() -> @@ -66,16 +73,45 @@ internal class UpdateDataStateTransformer( } else { initialSearchBarFactory() }, - collections = nftCollections + collections = walletNFTCollections + ?.let { createCollections(it) } + ?: createNFTsUM(nftCollections).toPersistentList(), + warnings = transformNotifications(), + onReceiveClick = onReceiveClick, + ) + + private fun NFTCollectionsStateUM.createCollections(walletNFTCollections: WalletNFTCollections) = + if (isAccountMode) { + val result = mutableListOf() + walletNFTCollections.collections.forEach { (account, nfts) -> + if (nfts.isEmpty()) return@forEach + result.add(account.toAccountPortfolioUM()) + result.addAll(createNFTsUM(nfts)) + } + result.toPersistentList() + } else { + val mainAccountCollection = walletNFTCollections.collections.values.firstOrNull() ?: listOf() + createNFTsUM(mainAccountCollection).toPersistentList() + } + + private fun Account.toAccountPortfolioUM(): NFTCollectionPortfolioUM = NFTCollectionPortfolioUM( + id = this.accountId.value, + title = AccountTitleUM.Account( + prefixText = TextReference.EMPTY, + name = this.accountName.toUM().value, + icon = when (this) { + is Account.CryptoPortfolio -> this.icon.toUM() + }, + ), + ) + + private fun NFTCollectionsStateUM.createNFTsUM(nftCollections: List): Sequence = + nftCollections .map { it.content } .asSequence() .filterIsInstance() .map { it.collections.orEmpty().transform(this) } .flatten() - .toPersistentList(), - warnings = transformNotifications(), - onReceiveClick = onReceiveClick, - ) private fun List.transform(state: NFTCollectionsStateUM): ImmutableList = map { NFTCollectionUM( @@ -97,6 +133,8 @@ internal class UpdateDataStateTransformer( }.toPersistentList() private fun transformNotifications(): ImmutableList = buildList { + val nftCollections = walletNFTCollections?.flattenCollections + ?: this@UpdateDataStateTransformer.nftCollections if (nftCollections.anyCollectionFailed()) { add( NFTCollectionsWarningUM( @@ -152,6 +190,7 @@ internal class UpdateDataStateTransformer( private fun NFTCollection.isExpanded(state: NFTCollectionsStateUM): Boolean = (state.content as? NFTCollectionsUM.Content) ?.collections + ?.filterIsInstance() ?.firstOrNull { it.id == this.collectionIdProvider() } ?.isExpanded ?: false diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt index 5a5fe32b0f..20f1fc3414 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt @@ -7,6 +7,8 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.nft.FetchNFTCollectionAssetsUseCase import com.tangem.domain.nft.GetNFTCollectionsUseCase import com.tangem.domain.nft.RefreshAllNFTUseCase @@ -31,6 +33,8 @@ internal class NFTCollectionsModel @Inject constructor( private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase, private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase, private val refreshAllNFTUseCase: RefreshAllNFTUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, paramsContainer: ParamsContainer, ) : Model() { @@ -62,7 +66,11 @@ internal class NFTCollectionsModel @Inject constructor( } init { - subscribeToNFTCollections() + if (accountsFeatureToggles.isFeatureEnabled) { + subscribeToNFTCollectionsNew() + } else { + subscribeToNFTCollections() + } } private fun subscribeToNFTCollections() { @@ -91,6 +99,38 @@ internal class NFTCollectionsModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeToNFTCollectionsNew() { + combine( + flow = getNFTCollectionsUseCase.invokeForAccounts(params.userWalletId), + flow2 = searchManager.query.distinctUntilChanged(), + flow3 = isAccountsModeEnabledUseCase(), + ) { nftCollections, query, isAccountMode -> + val filteredNFTs = nftCollections.collections + .mapValues { (_, nfts) -> nfts.filter(query) } + + _state.update { + UpdateDataStateTransformer( + nftCollections = listOf(), + isAccountMode = isAccountMode, + walletNFTCollections = nftCollections.copy(collections = filteredNFTs), + onReceiveClick = { + params.onReceiveClick() + }, + onRetryClick = ::onRefresh, + onExpandCollectionClick = ::onExpandCollectionClick, + onRetryAssetsClick = ::onRetryAssetsClick, + onAssetClick = { asset, collection -> + params.onAssetClick(asset, collection) + }, + initialSearchBarFactory = ::getInitialSearchBar, + collectionIdProvider = collectionIdProvider, + ).transform(it) + } + } + .onStart { onRefresh() } + .launchIn(modelScope) + } + private fun List.filter(query: String): List = map { it.copy( content = when (val content = it.content) { diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt index e53a86b841..a134698b02 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt @@ -8,30 +8,38 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.notifications.NotificationConfig -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.nft.collections.entity.* import com.tangem.features.nft.impl.R import kotlinx.collections.immutable.persistentListOf +import java.util.UUID @Suppress("LongMethod") @Composable @@ -89,41 +97,24 @@ internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: .padding( top = TangemTheme.dimens.spacing16, bottom = bottomPadding, - ) - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.primary), + ), + // .clip(TangemTheme.shapes.roundedCornersXMedium) + // .background(TangemTheme.colors.background.primary), state = listState, ) { - content.collections.fastForEach { collection -> - item(key = collection.id) { - NFTCollection( - modifier = Modifier.fillMaxWidth(), - state = collection, + content.collections.fastForEachIndexed { index, item -> + val previousItem = content.collections.getOrNull(index.dec()) + val nextItem = content.collections.getOrNull(index.inc()) + when (item) { + is NFTCollectionPortfolioUM -> nftPortfolioItem( + item = item, + shape = getRoundShape(item, previousItem, nextItem), + ) + is NFTCollectionUM -> nftCollectionItem( + collection = item, + shape = getRoundShape(item, previousItem, nextItem), + isNextItemNFTCollection = nextItem is NFTCollectionUM, ) - } - when (val assets = collection.assets) { - is NFTCollectionAssetsListUM.Init -> Unit - is NFTCollectionAssetsListUM.Loading -> { - assetsListLoading( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, - ) - } - is NFTCollectionAssetsListUM.Failed -> { - assetsListFailed( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, - ) - } - is NFTCollectionAssetsListUM.Content -> { - assetsListContent( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, - ) - } } } } @@ -139,9 +130,94 @@ internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: } } +private fun getRoundShape( + item: NFTCollectionItem, + previousItem: NFTCollectionItem?, + nextItem: NFTCollectionItem?, +): RoundedCornerShape { + val radius = 16.dp + val topRound = RoundedCornerShape(topStart = radius, topEnd = radius) + val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + val allRound = RoundedCornerShape(size = radius) + return when (item) { + is NFTCollectionPortfolioUM -> topRound + is NFTCollectionUM -> when { + previousItem == null && nextItem == null -> allRound + previousItem == null && nextItem is NFTCollectionUM -> topRound + previousItem != null && nextItem !is NFTCollectionUM -> bottomRound + else -> RoundedCornerShape(0.dp) + } + } +} + +private fun LazyListScope.nftPortfolioItem(item: NFTCollectionPortfolioUM, shape: RoundedCornerShape) { + item(item.id) { + AccountTitle( + textColor = TangemTheme.colors.text.primary1, + textStyle = TangemTheme.typography.caption1, + accountTitleUM = item.title, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clip(shape) + .background(TangemTheme.colors.background.action) + .padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 8.dp), + ) + } +} + +private fun LazyListScope.nftCollectionItem( + collection: NFTCollectionUM, + shape: RoundedCornerShape, + isNextItemNFTCollection: Boolean, +) { + item(key = collection.id) { + val itemShape = if (collection.isExpanded) { + shape.copy(bottomEnd = CornerSize(0.dp), bottomStart = CornerSize(0.dp)) + } else { + shape + } + NFTCollection( + modifier = Modifier + .clip(itemShape) + .background(TangemTheme.colors.background.action) + .fillMaxWidth(), + state = collection, + ) + } + when (val assets = collection.assets) { + is NFTCollectionAssetsListUM.Init -> Unit + is NFTCollectionAssetsListUM.Loading -> { + assetsListLoading( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + isNextItemNFTCollection = isNextItemNFTCollection, + ) + } + is NFTCollectionAssetsListUM.Failed -> { + assetsListFailed( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + isNextItemNFTCollection = isNextItemNFTCollection, + ) + } + is NFTCollectionAssetsListUM.Content -> { + assetsListContent( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + isNextItemNFTCollection = isNextItemNFTCollection, + ) + } + } +} + private fun LazyListScope.assetsListLoading( collectionId: String, content: NFTCollectionAssetsListUM.Loading, + isNextItemNFTCollection: Boolean, expanded: Boolean, ) { val itemsCount = content.itemsCount @@ -164,6 +240,8 @@ private fun LazyListScope.assetsListLoading( Row( modifier = Modifier .fillMaxWidth() + .clip(assetsShape(isNextItemNFTCollection)) + .background(TangemTheme.colors.background.action) .padding(TangemTheme.dimens.spacing6), ) { NFTCollectionAssetLoading( @@ -193,6 +271,7 @@ private fun LazyListScope.assetsListLoading( private fun LazyListScope.assetsListFailed( collectionId: String, content: NFTCollectionAssetsListUM.Failed, + isNextItemNFTCollection: Boolean, expanded: Boolean, ) { item( @@ -206,6 +285,8 @@ private fun LazyListScope.assetsListFailed( Box( modifier = Modifier .fillMaxWidth() + .clip(assetsShape(isNextItemNFTCollection)) + .background(TangemTheme.colors.background.action) .height(TangemTheme.dimens.size142), contentAlignment = Alignment.Center, ) { @@ -220,6 +301,7 @@ private fun LazyListScope.assetsListFailed( private fun LazyListScope.assetsListContent( collectionId: String, content: NFTCollectionAssetsListUM.Content, + isNextItemNFTCollection: Boolean, expanded: Boolean, ) { val items = content.items @@ -244,6 +326,10 @@ private fun LazyListScope.assetsListContent( ) Row( modifier = Modifier + .conditional(rowIndex.inc() == rowCount) { + clip(assetsShape(isNextItemNFTCollection)) + } + .background(TangemTheme.colors.background.action) .fillMaxWidth() .padding(TangemTheme.dimens.spacing6), ) { @@ -277,105 +363,150 @@ private fun LazyListScope.assetsListContent( } } +private fun assetsShape(isNextItemNFTCollection: Boolean): Shape = if (isNextItemNFTCollection) { + RoundedCornerShape(0.dp) +} else { + RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) +} + @Suppress("LongMethod") @Preview(widthDp = 360, showBackground = true) @Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview_NFTCollectionsContent() { +private fun Preview_NFTCollectionsContent(@PreviewParameter(PreviewProvider::class) state: NFTCollectionsUM.Content) { TangemThemePreview { NFTCollectionsContent( - content = NFTCollectionsUM.Content( - search = SearchBarUM( - placeholderText = resourceReference(R.string.common_search), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = { }, + content = state, + ) + } +} + +private class PreviewProvider : PreviewParameterProvider { + + val search + get() = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = { }, + ) + + val warnings + get() = persistentListOf( + NFTCollectionsWarningUM( + id = "loading troubles", + config = NotificationConfig( + title = TextReference.Res(R.string.nft_collections_warning_title), + subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle), + iconResId = R.drawable.ic_alert_triangle_20, ), - collections = persistentListOf( - NFTCollectionUM( + ), + ) + + val collection + get() = NFTCollectionUM( + id = UUID.randomUUID().toString(), + name = "Nethers", + logoUrl = "", + networkIconId = R.drawable.img_eth_22, + description = TextReference.Str("3 items"), + assets = NFTCollectionAssetsListUM.Content(persistentListOf()), + isExpanded = false, + onExpandClick = { }, + ) + + val collectionLoading + get() = collection.copy( + assets = NFTCollectionAssetsListUM.Loading( + itemsCount = 1, + ), + isExpanded = true, + ) + + val collectionFailed + get() = collection.copy( + assets = NFTCollectionAssetsListUM.Failed( + onRetryClick = { }, + ), + isExpanded = true, + ) + + val collectionContent + get() = collection.copy( + assets = NFTCollectionAssetsListUM.Content( + items = persistentListOf( + NFTCollectionAssetUM( id = "item1", - name = "Nethers", - logoUrl = "", - networkIconId = R.drawable.img_eth_22, - description = TextReference.Str("3 items"), - assets = NFTCollectionAssetsListUM.Content(persistentListOf()), - isExpanded = false, - onExpandClick = { }, + name = "Nethers #0854", + imageUrl = "img", + price = NFTSalePriceUM.Content( + price = stringReference("0.05 ETH"), + ), + onItemClick = { }, ), - NFTCollectionUM( + NFTCollectionAssetUM( id = "item2", - name = "Nethers", - logoUrl = "", - networkIconId = R.drawable.img_eth_22, - description = TextReference.Str("3 items"), - assets = NFTCollectionAssetsListUM.Loading( - itemsCount = 1, - ), - isExpanded = true, - onExpandClick = { }, + name = "Nethers #0855", + imageUrl = "img", + price = NFTSalePriceUM.Loading, + onItemClick = { }, ), - NFTCollectionUM( + NFTCollectionAssetUM( id = "item3", - name = "Nethers", - logoUrl = "", - networkIconId = R.drawable.img_eth_22, - description = TextReference.Str("3 items"), - assets = NFTCollectionAssetsListUM.Failed( - onRetryClick = { }, - ), - isExpanded = true, - onExpandClick = { }, - ), - NFTCollectionUM( - id = "item4", - name = "Nethers", - logoUrl = "", - networkIconId = R.drawable.img_eth_22, - description = TextReference.Str("3 items"), - assets = NFTCollectionAssetsListUM.Content( - items = persistentListOf( - NFTCollectionAssetUM( - id = "item1", - name = "Nethers #0854", - imageUrl = "img", - price = NFTSalePriceUM.Content( - price = stringReference("0.05 ETH"), - ), - onItemClick = { }, - ), - NFTCollectionAssetUM( - id = "item2", - name = "Nethers #0855", - imageUrl = "img", - price = NFTSalePriceUM.Loading, - onItemClick = { }, - ), - NFTCollectionAssetUM( - id = "item3", - name = "Nethers #0856", - imageUrl = "img", - price = NFTSalePriceUM.Failed, - onItemClick = { }, - ), - ), - ), - isExpanded = true, - onExpandClick = { }, + name = "Nethers #0856", + imageUrl = "img", + price = NFTSalePriceUM.Failed, + onItemClick = { }, ), ), - warnings = persistentListOf( - NFTCollectionsWarningUM( - id = "loading troubles", - config = NotificationConfig( - title = TextReference.Res(R.string.nft_collections_warning_title), - subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle), - iconResId = R.drawable.ic_alert_triangle_20, - ), - ), + ), + isExpanded = true, + ) + + val accountHeader + get() = NFTCollectionPortfolioUM( + title = AccountTitleUM.Account( + icon = AccountIconPreviewData.randomAccountIcon(), + name = stringReference("Main Account"), + prefixText = TextReference.EMPTY, + ), + id = UUID.randomUUID().toString(), + ) + + override val values: Sequence + get() = sequenceOf( + NFTCollectionsUM.Content( + search = search, + collections = persistentListOf( + collection, + collectionLoading, + collectionFailed, + collectionContent, ), + warnings = warnings, + onReceiveClick = { }, + ), + NFTCollectionsUM.Content( + search = search, + collections = persistentListOf( + accountHeader, + collection, + accountHeader, + collectionContent, + collection, + ), + warnings = warnings, + onReceiveClick = { }, + ), + NFTCollectionsUM.Content( + search = search, + collections = persistentListOf( + collectionContent, + collection, + ), + warnings = warnings, onReceiveClick = { }, ), ) - } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt index 6aa2bc4a8f..6c9581913e 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -4,7 +4,12 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop @@ -13,7 +18,13 @@ import com.arkivanov.decompose.value.subscribe 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.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.models.PortfolioId +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.account.PortfolioSelectorController import com.tangem.features.nft.collections.NFTCollectionsComponent import com.tangem.features.nft.common.ui.NFTContent import com.tangem.features.nft.component.NFTComponent @@ -23,20 +34,26 @@ import com.tangem.features.nft.entity.NFTSendSuccessListener import com.tangem.features.nft.receive.NFTReceiveComponent import com.tangem.features.nft.traits.NFTAssetTraitsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.serializer +@Suppress("LongParameterList") internal class DefaultNFTComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: NFTComponent.Params, private val nftDetailsInfoComponentFactory: NFTDetailsInfoComponent.Factory, nftSendSuccessListener: NFTSendSuccessListener, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, + private val portfolioSelectorController: PortfolioSelectorController, + portfolioFetcherFactory: PortfolioFetcher.Factory, + private val accountsFeatureToggles: AccountsFeatureToggles, ) : NFTComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -48,6 +65,26 @@ internal class DefaultNFTComponent @AssistedInject constructor( private val initialRoute: NFTRoute = NFTRoute.Collections(params.userWalletId) private val currentRoute = MutableStateFlow(initialRoute) + private val onReceiveClickJob = JobHolder() + private val portfolioFetcher: PortfolioFetcher? = if (accountsFeatureToggles.isFeatureEnabled) { + portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.Wallet(params.userWalletId), + scope = componentScope, + ) + } else { + null + } + private val bottomSheetNavigation: SlotNavigation = SlotNavigation() + private val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback { + override val onDismiss: () -> Unit = { bottomSheetNavigation.dismiss() } + override val onBack: () -> Unit = { bottomSheetNavigation.dismiss() } + } + private val bottomSheetSlot = childSlot( + source = bottomSheetNavigation, + serializer = Unit.serializer(), + handleBackButton = false, + childFactory = { configuration, context -> bottomSheetChild(context) }, + ) private val childStack = childStack( key = "sendInnerStack", @@ -90,6 +127,8 @@ internal class DefaultNFTComponent @AssistedInject constructor( NFTContent( stackState = stackState, ) + val bottomSheet by bottomSheetSlot.subscribeAsState() + bottomSheet.child?.instance?.BottomSheet() } private fun createChild(route: NFTRoute, factoryContext: AppComponentContext) = when (route) { @@ -108,11 +147,15 @@ internal class DefaultNFTComponent @AssistedInject constructor( userWalletId = route.userWalletId, onBackClick = ::onChildBack, onReceiveClick = { - innerRouter.push( - NFTRoute.Receive( - userWalletId = route.userWalletId, - ), - ) + if (accountsFeatureToggles.isFeatureEnabled) { + onReceiveClick(route) + } else { + innerRouter.push( + NFTRoute.Receive( + portfolioId = PortfolioId(route.userWalletId), + ), + ) + } }, onAssetClick = { asset, collection -> innerRouter.push( @@ -126,14 +169,31 @@ internal class DefaultNFTComponent @AssistedInject constructor( ), ) + private fun onReceiveClick(route: NFTRoute.Collections) = componentScope.launch { + val portfolioFetcher = requireNotNull(portfolioFetcher) + portfolioSelectorController.selectAccount(null) + portfolioFetcher.updateMode(mode = PortfolioFetcher.Mode.Wallet(route.userWalletId)) + val portfolioData = portfolioFetcher.data.first() + if (portfolioData.isSingleChoice) { + val mainAccountId = portfolioData.balances.values.first() + .accountsBalance.mainAccount.account.accountId + innerRouter.push(NFTRoute.Receive(portfolioId = PortfolioId(mainAccountId))) + } else { + bottomSheetNavigation.activate(Unit) + val selectedAccountId = portfolioSelectorController.selectedAccount + .filterNotNull().first() + bottomSheetNavigation.dismiss() + innerRouter.push(NFTRoute.Receive(portfolioId = PortfolioId(selectedAccountId))) + } + }.saveIn(onReceiveClickJob) + private fun getReceiveComponent( factoryContext: AppComponentContext, route: NFTRoute.Receive, ): ComposableContentComponent = NFTReceiveComponent( context = factoryContext, params = NFTReceiveComponent.Params( - userWalletId = route.userWalletId, - walletName = params.walletName, + portfolioId = route.portfolioId, onBackClick = ::onChildBack, ), tokenReceiveComponentFactory = tokenReceiveComponentFactory, @@ -181,6 +241,16 @@ internal class DefaultNFTComponent @AssistedInject constructor( } } + private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent = + portfolioSelectorComponentFactory.create( + context = childByContext(componentContext), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = portfolioFetcher!!, + controller = portfolioSelectorController, + bsCallback = portfolioSelectorCallback, + ), + ) + @AssistedFactory interface Factory : NFTComponent.Factory { override fun create(context: AppComponentContext, params: NFTComponent.Params): DefaultNFTComponent diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt index 56bad033c8..aa810f1b81 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt @@ -1,6 +1,7 @@ package com.tangem.features.nft.common import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.models.PortfolioId import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.models.wallet.UserWalletId @@ -15,7 +16,7 @@ internal sealed class NFTRoute : Route { @Serializable data class Receive( - val userWalletId: UserWalletId, + val portfolioId: PortfolioId, ) : NFTRoute() @Serializable diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt index e4bd9a112e..560eee70b5 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt @@ -13,8 +13,8 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.nft.receive.model.NFTReceiveModel import com.tangem.features.nft.receive.ui.NFTReceive import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -57,8 +57,7 @@ internal class NFTReceiveComponent @AssistedInject constructor( ) data class Params( - val userWalletId: UserWalletId, - val walletName: String, + val portfolioId: PortfolioId, val onBackClick: () -> Unit, ) } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt index 1a9c902d2a..e9ebb194f0 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.nft.receive.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.ui.account.toUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -11,19 +12,28 @@ import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.account.supplier.SingleAccountSupplier +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.FilterNFTAvailableNetworksUseCase import com.tangem.domain.nft.GetNFTCurrencyUseCase import com.tangem.domain.nft.GetNFTNetworkStatusUseCase import com.tangem.domain.nft.GetNFTNetworksUseCase import com.tangem.domain.nft.analytics.NFTAnalyticsEvent import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.nft.impl.R import com.tangem.features.nft.receive.NFTReceiveComponent import com.tangem.features.nft.receive.entity.NFTReceiveUM @@ -53,6 +63,9 @@ internal class NFTReceiveModel @Inject constructor( private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, private val getNFTCurrencyUseCase: GetNFTCurrencyUseCase, private val receiveAddressesFactory: ReceiveAddressesFactory, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val singleAccountSupplier: SingleAccountSupplier, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, paramsContainer: ParamsContainer, ) : Model() { @@ -63,10 +76,7 @@ internal class NFTReceiveModel @Inject constructor( private val _state = MutableStateFlow( value = NFTReceiveUM( onBackClick = params.onBackClick, - appBarSubtitle = resourceReference( - R.string.hot_crypto_add_token_subtitle, - formatArgs = wrappedList(params.walletName), - ), + appBarSubtitle = TextReference.EMPTY, search = getInitialSearchBar(), networks = NFTReceiveUM.Networks.Content( availableItems = persistentListOf(), @@ -81,11 +91,44 @@ internal class NFTReceiveModel @Inject constructor( init { analyticsEventHandler.send(NFTAnalyticsEvent.Receive.ScreenOpened) subscribeToNFTAvailableNetworks() + loadPortfolioName() } + private fun loadPortfolioName() = modelScope.launch(dispatchers.default) { + val appBarSubtitle = when (val portfolioId = params.portfolioId) { + is PortfolioId.Wallet -> loadWalletName(portfolioId.userWalletId) + is PortfolioId.Account -> if (isAccountsModeEnabledUseCase.invokeSync()) { + loadAccountName(portfolioId.accountId) + } else { + loadWalletName(portfolioId.userWalletId) + } + } + _state.update { it.copy(appBarSubtitle = appBarSubtitle) } + } + + private fun loadWalletName(userWalletId: UserWalletId): TextReference { + return getUserWalletUseCase(userWalletId) + .map { it.name } + .getOrNull() + ?.let { createAppBarSubtitle(stringReference(it)) } + ?: TextReference.EMPTY + } + + private suspend fun loadAccountName(accountId: AccountId): TextReference { + return singleAccountSupplier + .getSyncOrNull(SingleAccountProducer.Params(accountId)) + ?.let { createAppBarSubtitle(it.accountName.toUM().value) } + ?: TextReference.EMPTY + } + + private fun createAppBarSubtitle(text: TextReference) = resourceReference( + R.string.hot_crypto_add_token_subtitle, + formatArgs = wrappedList(text), + ) + private fun subscribeToNFTAvailableNetworks() { combine( - flow = getNFTNetworksUseCase(params.userWalletId), + flow = getNFTNetworksUseCase(params.portfolioId), flow2 = searchManager.query.distinctUntilChanged(), ) { networks, query -> filterNFTAvailableNetworksUseCase(networks, query) @@ -98,6 +141,7 @@ internal class NFTReceiveModel @Inject constructor( ).transform(it) } } + .flowOn(dispatchers.default) .launchIn(modelScope) } @@ -144,7 +188,7 @@ internal class NFTReceiveModel @Inject constructor( analyticsEventHandler.send(NFTAnalyticsEvent.Receive.BlockchainChosen(network.name)) val networkStatus = getNFTNetworkStatusUseCase.invoke( - userWalletId = params.userWalletId, + userWalletId = params.portfolioId.userWalletId, network = network, ) ?: return@launch @@ -191,7 +235,7 @@ internal class NFTReceiveModel @Inject constructor( private suspend fun configureReceiveAddresses(addresses: NetworkAddress, network: Network): TokenReceiveConfig { val cryptoCurrency = getNFTCurrencyUseCase.invoke(network) return receiveAddressesFactory.createForNft( - userWalletId = params.userWalletId, + userWalletId = params.portfolioId.userWalletId, addresses = addresses, network = network, nft = cryptoCurrency, diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/util/ResetCardsComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/util/ResetCardsComponent.kt new file mode 100644 index 0000000000..bd3a3203e2 --- /dev/null +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/util/ResetCardsComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onboarding.v2.util + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.domain.models.wallet.UserWallet + +interface ResetCardsComponent { + + fun startResetCardsFlow(createdUserWallet: UserWallet.Cold) + + data class Params( + val callbacks: ModelCallbacks, + ) + + interface ModelCallbacks { + fun onCancel() + fun onComplete() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index 3d0f4e7079..279319ac62 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -95,6 +95,7 @@ internal class OnboardingEntryModel @Inject constructor( withSeedPhraseFlow = scanResponse.productType != ProductType.Wallet, titleProvider = titleProvider, onDone = ::onMultiWalletOnboardingDone, + onForceExit = ::popOrExitComponentScreen, mode = multiWalletNavigationMode, ) } @@ -204,6 +205,14 @@ internal class OnboardingEntryModel @Inject constructor( } } + private fun popOrExitComponentScreen() { + router.pop(onComplete = { success -> + if (!success) { + exitComponentScreen() + } + }) + } + private fun exitComponentScreen() { // new flow if (hotWalletFeatureToggles.isHotWalletEnabled) { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt index 85e1c6dac5..911596766b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt @@ -36,6 +36,7 @@ internal class OnboardingChildFactory @Inject constructor( withSeedPhraseFlow = route.withSeedPhraseFlow, titleProvider = route.titleProvider, onDone = route.onDone, + onForceExit = route.onForceExit, mode = route.mode, ), ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt index c43edc87ea..a37ce1dd18 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt @@ -25,6 +25,7 @@ sealed class OnboardingRoute : Route { val withSeedPhraseFlow: Boolean, val mode: OnboardingMultiWalletComponent.Mode, val onDone: (UserWallet.Cold) -> Unit, + val onForceExit: () -> Unit, ) : OnboardingRoute() data class Visa( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt index 75a31024e6..268966cf50 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt @@ -16,6 +16,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi val withSeedPhraseFlow: Boolean, val mode: Mode, val onDone: (UserWallet.Cold) -> Unit, + val onForceExit: () -> Unit, ) sealed class Mode { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 6a1d3cf78e..f13142d4d5 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -41,6 +41,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiW import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.Step.* import com.tangem.features.onboarding.v2.multiwallet.impl.ui.OnboardingMultiWallet import com.tangem.features.onboarding.v2.multiwallet.impl.ui.WalletArtworksState +import com.tangem.features.onboarding.v2.util.ResetCardsComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -51,6 +52,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor @Assisted private val context: AppComponentContext, @Assisted private val params: OnboardingMultiWalletComponent.Params, private val analyticsHandler: AnalyticsEventHandler, + private val resetCardsComponentFactory: ResetCardsComponent.Factory, ) : OnboardingMultiWalletComponent, AppComponentContext by context { private val model: OnboardingMultiWalletModel = getOrCreateModel(params) @@ -178,6 +180,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor ) Finalize -> MultiWalletFinalizeComponent( context = childContext, + resetCardsComponentFactory = resetCardsComponentFactory, params = childParams, backButtonClickFlow = backButtonClickFlow, onBack = { model.onBack() }, @@ -249,6 +252,9 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor MultiWalletFinalizeComponent.Event.ThreeBackupCardsAdded -> { handleNavigationEvent(Done) } + MultiWalletFinalizeComponent.Event.ExitFromFlow -> { + params.onForceExit() + } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt index 5c8f75423b..824770dc83 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt @@ -6,6 +6,7 @@ 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.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.onboarding.v2.impl.R @@ -13,13 +14,17 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model.MultiWalletFinalizeModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.MultiWalletFinalize +import com.tangem.features.onboarding.v2.util.ResetCardsComponent import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Suppress("UnusedPrivateMember") internal class MultiWalletFinalizeComponent( context: AppComponentContext, + resetCardsComponentFactory: ResetCardsComponent.Factory, params: MultiWalletChildParams, backButtonClickFlow: SharedFlow, onBack: () -> Unit, @@ -28,6 +33,13 @@ internal class MultiWalletFinalizeComponent( private val model: MultiWalletFinalizeModel = getOrCreateModel(params) + private val resetCardsComponent = resetCardsComponentFactory.create( + context = child("ResetCardsComponent"), + params = ResetCardsComponent.Params( + callbacks = model.resetCardsModelCallbacks, + ), + ) + init { params.innerNavigation.update { it.copy( @@ -51,6 +63,10 @@ internal class MultiWalletFinalizeComponent( componentScope.launch { model.onBackFlow.collect { onBack() } } + + model.startFullResetFlow + .onEach { resetCardsComponent.startResetCardsFlow(it) } + .launchIn(componentScope) } @Composable @@ -66,6 +82,6 @@ internal class MultiWalletFinalizeComponent( } enum class Event { - OneBackupCardAdded, TwoBackupCardsAdded, ThreeBackupCardsAdded + OneBackupCardAdded, TwoBackupCardsAdded, ThreeBackupCardsAdded, ExitFromFlow } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 65dd32233d..d4436b86c7 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -31,6 +31,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.MultiWa import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state.MultiWalletFinalizeUM import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage.* +import com.tangem.features.onboarding.v2.util.ResetCardsComponent import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder import com.tangem.sdk.api.TangemSdkManager @@ -62,6 +63,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( private val onboardingRepository: OnboardingRepository, private val walletsRepository: WalletsRepository, private val uiMessageSender: UiMessageSender, + private val backupValidator: BackupValidator, ) : Model() { private val params = paramsContainer.require() @@ -76,6 +78,8 @@ internal class MultiWalletFinalizeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onBackFlow = MutableSharedFlow() val onEvent = MutableSharedFlow() + val resetCardsModelCallbacks = ResetCardsModelCallbacks() + val startFullResetFlow = MutableSharedFlow() init { modelScope.launch { @@ -229,11 +233,24 @@ internal class MultiWalletFinalizeModel @Inject constructor( } } + @Suppress("LongMethod") private fun finishBackup() { modelScope.launch { + setLoading(true) val scanResponse = params.multiWalletState.value.currentScanResponse val userWalletCreated = createUserWallet(scanResponse) + // Validate wallet before saving + // If something went wrong - start full reset flow + if (walletHasBackupError || !backupValidator.isValidFull(scanResponse.card)) { + startFullResetFlow.emit( + userWalletCreated.copy( + scanResponse = scanResponse.updateScanResponseAfterBackup(), + ), + ) + return@launch + } + val userWallet = when (params.parentParams.mode) { OnboardingMultiWalletComponent.Mode.Onboarding, OnboardingMultiWalletComponent.Mode.ContinueFinalize, @@ -290,6 +307,10 @@ internal class MultiWalletFinalizeModel @Inject constructor( launch(NonCancellable) { walletsRepository.markWallet2WasCreated(userWallet.walletId) } } + if (userWallet.isMultiCurrency) { + launch(NonCancellable) { walletsRepository.createWallet(userWallet.walletId) } + } + // user wallet is fully created and saved, remove scan response from preferences // to prevent showing finalize screen dialog on next app start onboardingRepository.clearUnfinishedFinalizeOnboarding() @@ -359,4 +380,25 @@ internal class MultiWalletFinalizeModel @Inject constructor( val mask = "$space*$space*$space*$space" return mask + last4 } + + private fun setLoading(isLoading: Boolean) { + _uiState.update { it.copy(isButtonLoading = isLoading) } + } + + inner class ResetCardsModelCallbacks : ResetCardsComponent.ModelCallbacks { + + override fun onCancel() { + modelScope.launch { + onEvent.emit(MultiWalletFinalizeComponent.Event.ExitFromFlow) + } + } + + override fun onComplete() { + modelScope.launch { + onboardingRepository.clearUnfinishedFinalizeOnboarding() + backupServiceHolder.backupService.get()?.discardSavedBackup() + onEvent.emit(MultiWalletFinalizeComponent.Event.ExitFromFlow) + } + } + } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/MultiWalletFinalize.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/MultiWalletFinalize.kt index 24a3780233..944b1f2a60 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/MultiWalletFinalize.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/MultiWalletFinalize.kt @@ -72,6 +72,7 @@ internal fun MultiWalletFinalize(state: MultiWalletFinalizeUM, modifier: Modifie text = stringResourceSafe( if (state.isRing) R.string.onboarding_button_backup_ring else R.string.onboarding_button_backup_origin, ), + showProgress = state.isButtonLoading, iconResId = R.drawable.ic_tangem_24, onClick = state.onScanClick, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt index 00387df368..ddbed0afd2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt @@ -4,6 +4,7 @@ import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM internal data class MultiWalletFinalizeUM( val step: Step = Step.Primary, + val isButtonLoading: Boolean = false, val isRing: Boolean = false, val scanPrimary: Boolean = true, val cardNumber: String = "", diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/DefaultResetCardsComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/DefaultResetCardsComponent.kt new file mode 100644 index 0000000000..e7d6d281a1 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/DefaultResetCardsComponent.kt @@ -0,0 +1,30 @@ +package com.tangem.features.onboarding.v2.util.impl + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.onboarding.v2.util.ResetCardsComponent +import com.tangem.features.onboarding.v2.util.impl.model.ResetCardsModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultResetCardsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: ResetCardsComponent.Params, +) : ResetCardsComponent, AppComponentContext by context { + + private val model: ResetCardsModel = getOrCreateModel(params) + + override fun startResetCardsFlow(createdUserWallet: UserWallet.Cold) { + model.startResetCardsFlow(createdUserWallet) + } + + @AssistedFactory + interface Factory : ResetCardsComponent.Factory { + override fun create( + context: AppComponentContext, + params: ResetCardsComponent.Params, + ): DefaultResetCardsComponent + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/di/ComponentModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..97f08e8c17 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/di/ComponentModule.kt @@ -0,0 +1,32 @@ +package com.tangem.features.onboarding.v2.util.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onboarding.v2.util.ResetCardsComponent +import com.tangem.features.onboarding.v2.util.impl.DefaultResetCardsComponent +import com.tangem.features.onboarding.v2.util.impl.model.ResetCardsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindResetCardsComponent(factory: DefaultResetCardsComponent.Factory): ResetCardsComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface ModelModule { + @Binds + @IntoMap + @ClassKey(ResetCardsModel::class) + fun provideModel(model: ResetCardsModel): Model +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/model/ResetCardsModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/model/ResetCardsModel.kt new file mode 100644 index 0000000000..5a89688e94 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/util/impl/model/ResetCardsModel.kt @@ -0,0 +1,174 @@ +package com.tangem.features.onboarding.v2.util.impl.model + +import com.tangem.common.CompletionResult +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.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.card.common.util.getBackupCardsCount +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.util.ResetCardsComponent +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume + +@ModelScoped +internal class ResetCardsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val tangemSdkManager: TangemSdkManager, + private val uiMessageSender: UiMessageSender, +) : Model() { + + private val params = paramsContainer.require() + private val callbacks = params.callbacks + private val jobHolder = JobHolder() + + fun startResetCardsFlow(wallet: UserWallet.Cold) { + modelScope.launch { + startFullResetFlow(wallet) + }.saveIn(jobHolder) + } + + private suspend fun startFullResetFlow(createdUserWallet: UserWallet.Cold) { + suspendCancellableCoroutine { continuation -> + uiMessageSender.send( + DialogMessage.invoke( + title = resourceReference(R.string.reset_cards_dialog_first_title), + isDismissable = false, + message = resourceReference(R.string.reset_cards_dialog_first_description), + firstActionBuilder = { + cancelAction { + callbacks.onCancel() + onDismissRequest() + continuation.resume(Unit) + } + }, + secondActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.card_settings_action_sheet_reset), + isWarning = true, + onClick = { + modelScope.launch { + repeatUntilTrue { resetPrimaryCard(createdUserWallet) } + continuation.resume(Unit) + onDismissRequest() + startResetBackupCardsFlow(createdUserWallet) + } + }, + ) + }, + onDismissRequest = {}, + ), + ) + } + } + + private suspend fun startResetBackupCardsFlow(createdUserWallet: UserWallet.Cold) { + val backupCardsCount = createdUserWallet.scanResponse.getBackupCardsCount() ?: 0 + + if (backupCardsCount == 0) { + completeResetFlow() + return + } + + repeat(backupCardsCount) { index -> + suspendCancellableCoroutine { continuation -> + uiMessageSender.send( + DialogMessage.invoke( + title = resourceReference(R.string.card_settings_continue_reset_alert_title), + isDismissable = false, + message = resourceReference(R.string.reset_cards_dialog_next_device_description), + firstActionBuilder = { + cancelAction { + callbacks.onCancel() + onDismissRequest() + continuation.resume(Unit) + } + }, + secondActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.card_settings_action_sheet_reset), + isWarning = true, + onClick = { + modelScope.launch { + repeatUntilTrue { resetBackupCard(index + 1, createdUserWallet.walletId) } + onDismissRequest() + if (index == backupCardsCount - 1) { + completeResetFlow() + } + continuation.resume(Unit) + } + }, + ) + }, + onDismissRequest = {}, + ), + ) + } + } + } + + private suspend fun completeResetFlow() { + suspendCancellableCoroutine { continuation -> + uiMessageSender.send( + DialogMessage.invoke( + title = resourceReference(R.string.card_settings_completed_reset_alert_title), + isDismissable = false, + message = resourceReference(R.string.reset_cards_dialog_complete_description), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_done), + onClick = { + modelScope.launch { + onDismissRequest() + callbacks.onComplete() + continuation.resume(Unit) + } + }, + ) + }, + onDismissRequest = {}, + ), + ) + } + } + + private suspend fun resetPrimaryCard(createdUserWallet: UserWallet.Cold): Boolean { + val scanResponse = createdUserWallet.scanResponse + + val result = tangemSdkManager.resetToFactorySettings( + cardId = scanResponse.card.cardId, + allowsRequestAccessCodeFromRepository = true, + ) + + return when (result) { + is CompletionResult.Failure -> false + is CompletionResult.Success -> true + } + } + + private suspend fun resetBackupCard(cardIndex: Int, userWalletId: UserWalletId): Boolean { + val result = tangemSdkManager.resetBackupCard(cardIndex, userWalletId) + return when (result) { + is CompletionResult.Failure -> false + is CompletionResult.Success -> true + } + } + + private inline fun repeatUntilTrue(action: () -> Boolean) { + while (true) { + if (action()) break + } + } +} \ No newline at end of file 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..6035f80287 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,75 @@ 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 { + val advantages = when (quote.error) { + is OnrampError.AmountError.TooSmallError -> OnrampOfferAdvantagesUM.Unavailable.MinAmount + is OnrampError.AmountError.TooBigError -> OnrampOfferAdvantagesUM.Unavailable.MaxAmount + } + + return OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = advantages, + 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..46ca3d4017 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,59 @@ 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, + unavailableStatus = methodUM.paymentMethodStatus, + ) + } } } } } +@Composable +private fun UnavailablePaymentMethodInfoBlock( + paymentMethodName: String, + errorAmount: String, + unavailableStatus: PaymentMethodStatus.Unavailable, +) { + 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) + val messageResId = when (unavailableStatus) { + is PaymentMethodStatus.Unavailable.MinAmount -> R.string.onramp_provider_min_amount + is PaymentMethodStatus.Unavailable.MaxAmount -> R.string.onramp_provider_max_amount + } + Text( + text = stringResourceSafe(messageResId, 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 +273,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 +287,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 +306,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..f731c6e29c 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,22 +9,14 @@ 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?, + val onrampAllOffersButtonConfig: OnrampAllOffersButtonConfig, ) : OnrampOffersBlockUM } @@ -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?, @@ -43,8 +34,16 @@ internal enum class OnrampOfferCategoryUM { RecentlyUsed, Recommended } -internal enum class OnrampOfferAdvantagesUM { - Default, BestRate, Fastest; +internal sealed interface OnrampOfferAdvantagesUM { + data object Default : OnrampOfferAdvantagesUM + data object BestRate : OnrampOfferAdvantagesUM + data object GreatRate : OnrampOfferAdvantagesUM + data object Fastest : OnrampOfferAdvantagesUM + + sealed interface Unavailable : OnrampOfferAdvantagesUM { + data object MinAmount : Unavailable + data object MaxAmount : Unavailable + } fun toAnalyticsEvent( cryptoCurrencySymbol: String, @@ -52,7 +51,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 +61,10 @@ internal enum class OnrampOfferAdvantagesUM { providerName = providerName, paymentMethod = paymentMethodName, ) - Default -> null + Default, + BestRate, + is 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..49041e269a 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 @@ -6,8 +6,11 @@ internal interface OnrampV2Intents { fun onAmountValueChanged(value: String) fun openSettings() fun openCurrenciesList() - fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) + fun onBuyClick( + quote: OnrampProviderWithQuote.Data, + onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM, + categoryUM: OnrampOfferCategoryUM, + ) 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..92adb96dbd 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 @@ -1,15 +1,12 @@ package com.tangem.features.onramp.mainv2.entity.factory import com.tangem.core.ui.extensions.TextReference -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.format.bigdecimal.percent import com.tangem.domain.onramp.model.* import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.mainv2.entity.* import com.tangem.utils.Provider -import com.tangem.utils.StringsSigns.MINUS import kotlinx.collections.immutable.toPersistentList internal class OnrampOffersStateFactory( @@ -17,15 +14,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 +28,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 -> @@ -46,12 +37,13 @@ internal class OnrampOffersStateFactory( is OnrampQuote.Error, -> Unit is OnrampQuote.Data -> { + val advantages = mapOfferAdvantagesDTOtoUM(offer.advantages) + val category = mapOfferCategoryDTOtoUM(block.category) allOffersUM.add( OnrampOfferUM( - category = mapOfferCategoryDTOtoUM(block.category), - advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), + category = category, + advantages = advantages, paymentMethod = currentQuote.paymentMethod, - providerId = currentQuote.provider.id, providerName = currentQuote.provider.info.name, rate = currentQuote.toAmount.value.format { crypto( @@ -59,9 +51,7 @@ internal class OnrampOffersStateFactory( decimals = currentQuote.toAmount.decimals, ) }, - diff = offer.rateDif?.let { diff -> - stringReference("$MINUS${diff.format { percent() }}") - }, + diff = null, // on main screen diff should not be shown onBuyClicked = { onrampIntents.onBuyClick( quote = OnrampProviderWithQuote.Data( @@ -70,7 +60,8 @@ internal class OnrampOffersStateFactory( toAmount = currentQuote.toAmount, fromAmount = currentQuote.fromAmount, ), - onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), + onrampOfferAdvantagesUM = advantages, + categoryUM = category, ) }, ), @@ -80,18 +71,15 @@ 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 }) { - OnrampAllOffersButtonConfig( - title = TextReference.Res(R.string.onramp_all_offers_button_title), - onClick = { onrampIntents.openProviders() }, - ) - } else { - null - }, + onrampAllOffersButtonConfig = OnrampAllOffersButtonConfig( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + onClick = { onrampIntents.openProviders() }, + ), ) } @@ -107,6 +95,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..66f4fbb5f8 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() @@ -126,7 +124,11 @@ internal class OnrampV2MainComponentModel @Inject constructor( bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList) } - override fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) { + override fun onBuyClick( + quote: OnrampProviderWithQuote.Data, + onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM, + categoryUM: OnrampOfferCategoryUM, + ) { val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return analyticsEventHandler.send( OnrampAnalyticsEvent.OnBuyClick( @@ -135,11 +137,11 @@ internal class OnrampV2MainComponentModel @Inject constructor( tokenSymbol = params.cryptoCurrency.symbol, ), ) - onrampOfferAdvantagesUM.toAnalyticsEvent( - cryptoCurrencySymbol = params.cryptoCurrency.symbol, - providerName = quote.provider.info.name, - paymentMethodName = quote.paymentMethod.name, - )?.let { analyticsEventHandler::send } + sendOfferClickEvent( + quote = quote, + onrampOfferAdvantagesUM = onrampOfferAdvantagesUM, + categoryUM = categoryUM, + ) params.openRedirectPage(quote) } @@ -160,27 +162,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()) @@ -228,24 +226,24 @@ internal class OnrampV2MainComponentModel @Inject constructor( } private fun subscribeOnOffers() = modelScope.launch { - getOnrampOffersUseCase.invoke( - userWalletId = userWallet.walletId, - cryptoCurrencyId = params.cryptoCurrency.id, - ).collectLatest { maybeOffers -> - maybeOffers.fold( - ifLeft = ::handleOnrampError, - ifRight = { offers -> - _state.update { onrampOffersStateFactory.getOnShowOffersState(offers) } - }, - ) - } + getOnrampOffersUseCase + .invoke() + .collectLatest { maybeOffers -> + maybeOffers.fold( + ifLeft = ::handleOnrampError, + ifRight = { offers -> + if (offers.isNotEmpty()) { + _state.update { onrampOffersStateFactory.getOffersState(offers) } + } + }, + ) + } } private fun subscribeToAmountChanges() = modelScope.launch { amountInputManager.query .filter(String::isNotEmpty) .collectLatest { _ -> - _state.update { amountStateFactory.getAmountSecondaryLoadingState() } startLoadingQuotes() } } @@ -288,74 +286,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 +325,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 +345,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( @@ -412,6 +354,29 @@ internal class OnrampV2MainComponentModel @Inject constructor( ) } + private fun sendOfferClickEvent( + quote: OnrampProviderWithQuote.Data, + onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM, + categoryUM: OnrampOfferCategoryUM, + ) { + when (categoryUM) { + OnrampOfferCategoryUM.RecentlyUsed -> { + OnrampAnalyticsEvent.RecentlyBuyClicked( + tokenSymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethod = quote.paymentMethod.name, + ).let(analyticsEventHandler::send) + } + OnrampOfferCategoryUM.Recommended -> { + onrampOfferAdvantagesUM.toAnalyticsEvent( + cryptoCurrencySymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethodName = quote.paymentMethod.name, + )?.let(analyticsEventHandler::send) + } + } + } + private companion object { const val UPDATE_DELAY = 10_000L } 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..eeb6351799 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 @@ -2,12 +2,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 @@ -35,27 +33,13 @@ import com.tangem.core.ui.test.OnrampOffersBlockTestTags import com.tangem.domain.onramp.model.OnrampPaymentMethod import com.tangem.domain.onramp.model.PaymentMethodType import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM -import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM -import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM -import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM +import com.tangem.features.onramp.mainv2.entity.* 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 { @@ -74,14 +58,19 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { } } - Text( - modifier = Modifier.padding(start = 12.dp), - text = stringResourceSafe(R.string.onramp_recommended_title), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - - SpacerH(8.dp) + AnimatedVisibility( + visible = state.recommended.isNotEmpty(), + ) { + if (state.recommended.isNotEmpty()) { + Text( + modifier = Modifier.padding(start = 12.dp), + text = stringResourceSafe(R.string.onramp_recommended_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH(8.dp) + } + } state.recommended.fastForEach { offer -> key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") { @@ -90,16 +79,23 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { } } - state.onrampAllOffersButtonConfig?.let { - SpacerH(12.dp) - SecondaryButton( - modifier = Modifier.fillMaxWidth(), - text = it.title.resolveReference(), - onClick = it.onClick, - ) - } + SpacerH(12.dp) + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = state.onrampAllOffersButtonConfig.title.resolveReference(), + onClick = state.onrampAllOffersButtonConfig.onClick, + ) } } + OnrampOffersBlockUM.Loading -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + LoadingBlock() + } + } + OnrampOffersBlockUM.Empty -> Unit } } @@ -120,13 +116,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 is OnrampOfferAdvantagesUM.Unavailable, + ) } SpacerWMax() - SecondaryButton( + PrimaryButton( size = TangemButtonSize.RoundedAction, text = stringResourceSafe(R.string.common_buy), onClick = onrampOfferUM.onBuyClicked, + enabled = onrampOfferUM.advantages !is OnrampOfferAdvantagesUM.Unavailable, ) } SpacerH(10.dp) @@ -142,6 +143,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 +178,28 @@ 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, + ) + } + is OnrampOfferAdvantagesUM.Unavailable -> { + val textResId = when (advantage) { + OnrampOfferAdvantagesUM.Unavailable.MinAmount -> R.string.onramp_title_available_from + OnrampOfferAdvantagesUM.Unavailable.MaxAmount -> R.string.onramp_title_available_up_to } + Text( + text = stringResourceSafe(textResId), + 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 +207,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 +324,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 +340,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 +374,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 +383,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 +391,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,16 +412,27 @@ 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%"), onBuyClicked = {}, ), ), - onrampAllOffersButtonConfig = null, + onrampAllOffersButtonConfig = OnrampAllOffersButtonConfig( + title = TextReference.Res(R.string.onramp_all_offers_button_title), + onClick = { }, + ), ) 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..803113ed5a 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) @@ -99,7 +92,11 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin symbol = currencyCode, currencyCode = currencyCode, decimalFormat = decimalFormat, - symbolColor = TangemTheme.colors.text.disabled, + symbolColor = if (amountField.fiatValue.isBlank()) { + TangemTheme.colors.text.disabled + } else { + TangemTheme.colors.text.primary1 + }, ), onValueChange = amountField.onValueChange, keyboardOptions = amountField.keyboardOptions, @@ -130,7 +127,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 +139,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 544f0f110d..fd6d531d4f 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/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 793262545b..30dcb3c687 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -11,6 +11,7 @@ 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 androidx.compose.ui.res.painterResource import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextAlign @@ -35,6 +36,7 @@ import com.tangem.core.ui.format.bigdecimal.fee import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.FeeSelectorBlockTestTags import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.send.v2.api.entity.* @@ -54,11 +56,14 @@ internal fun FeeSelectorBlockContent( Row( modifier = modifier .background(TangemTheme.colors.background.action) - .padding(12.dp), + .padding(12.dp) + .testTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK), verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .testTag(FeeSelectorBlockTestTags.ICON), painter = painterResource(R.drawable.ic_fee_new_24), contentDescription = null, tint = TangemTheme.colors.icon.accent, @@ -85,7 +90,8 @@ private fun FeeSelectorStaticPart(onReadMoreClick: () -> Unit, modifier: Modifie Text( modifier = Modifier .padding(start = TangemTheme.dimens.spacing4) - .weight(1f, fill = false), + .weight(1f, fill = false) + .testTag(FeeSelectorBlockTestTags.TITLE), text = stringResourceSafe(R.string.common_network_fee_title), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, @@ -123,7 +129,9 @@ private fun FeeSelectorStaticPart(onReadMoreClick: () -> Unit, modifier: Modifie .clip(CircleShape), content = { contentModifier -> Icon( - modifier = contentModifier.size(TangemTheme.dimens.size16), + modifier = contentModifier + .size(TangemTheme.dimens.size16) + .testTag(FeeSelectorBlockTestTags.TOOLTIP_ICON), painter = painterResource(id = R.drawable.ic_token_info_24), contentDescription = null, tint = TangemTheme.colors.icon.informative, @@ -174,11 +182,15 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi style = TangemTheme.typography.body1, color = TangemTheme.colors.text.tertiary, textAlign = TextAlign.End, - modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .testTag(FeeSelectorBlockTestTags.FEE_AMOUNT), ) if (!state.feeItems.isSingleItem()) { Icon( - modifier = Modifier.size(width = 18.dp, height = 24.dp), + modifier = Modifier + .size(width = 18.dp, height = 24.dp) + .testTag(FeeSelectorBlockTestTags.SELECT_FEE_ICON), painter = painterResource(id = R.drawable.ic_select_18_24), contentDescription = null, tint = TangemTheme.colors.icon.informative, 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..becc704e91 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().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..0fc9f3c4a6 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 @@ -6,9 +6,9 @@ import androidx.compose.ui.text.input.KeyboardType import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.v2.impl.R import com.tangem.utils.transformer.Transformer internal class SendDestinationInitialStateTransformer( @@ -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..444ee04cdd 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 @@ -11,6 +11,7 @@ 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.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -20,6 +21,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendConfirmScreenTestTags import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationTextFieldUM @@ -70,6 +72,7 @@ private fun AddressWithMemoBlock( text = address.value, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, + modifier = Modifier.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS), ) val blockchainAddress = address.briefBlockchainAddress if (!blockchainAddress.isNullOrBlank()) { @@ -77,6 +80,7 @@ private fun AddressWithMemoBlock( text = blockchainAddress, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SendConfirmScreenTestTags.BLOCKCHAIN_ADDRESS), ) } } @@ -85,7 +89,8 @@ private fun AddressWithMemoBlock( modifier = Modifier .size(TangemTheme.dimens.size36) .clip(RoundedCornerShape(TangemTheme.dimens.radius18)) - .background(TangemTheme.colors.background.tertiary), + .background(TangemTheme.colors.background.tertiary) + .testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON), ) } @@ -144,7 +149,6 @@ private class DestinationBlockPreviewProvider : PreviewParameterProvider diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt index bd9b01c007..44c610f63d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt @@ -17,6 +17,7 @@ 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 androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -36,6 +37,7 @@ import com.tangem.core.ui.extensions.rememberHapticFeedback import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.send.v2.impl.R import com.tangem.utils.StringsSigns @@ -85,6 +87,7 @@ fun ListItemWithIcon( } } +@Suppress("LongMethod") @Composable private fun ListItemWithIcon( title: String, @@ -109,7 +112,8 @@ private fun ListItemWithIcon( modifier = Modifier .padding(vertical = 8.dp) .size(40.dp) - .clip(RoundedCornerShape(20.dp)), + .clip(RoundedCornerShape(20.dp)) + .testTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON), ) Column( modifier = Modifier @@ -123,7 +127,7 @@ private fun ListItemWithIcon( color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Justify, ellipsis = TextEllipsis.Middle, - modifier = Modifier, + modifier = Modifier.testTag(SendAddressScreenTestTags.RECENT_ADDRESS_TITLE), ) Row(verticalAlignment = Alignment.CenterVertically) { if (subtitleIconRes != null) { @@ -151,6 +155,7 @@ private fun ListItemWithIcon( style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset), + modifier = Modifier.testTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT), ) if (accountTitleUM != null) { AccountTitle( 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/TextFieldWithPaste.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt index d8071c89c0..31cc7769f0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.CenterEnd import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.fields.SimpleTextField @@ -19,6 +20,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.components.containers.FooterContainer +import com.tangem.core.ui.test.SendAddressScreenTestTags @Suppress("LongMethod", "LongParameterList") @Composable @@ -61,6 +63,7 @@ internal fun TextFieldWithPaste( text = title.resolveReference(), style = labelStyle, color = color, + modifier = Modifier.testTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD_TITLE), ) SimpleTextField( value = value, @@ -71,7 +74,8 @@ internal fun TextFieldWithPaste( isValuePasted = isValuePasted, modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp), + .padding(top = 8.dp) + .testTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD), ) } AnimatedVisibility( @@ -84,6 +88,7 @@ internal fun TextFieldWithPaste( ) { CrossIcon( onClick = onPasteClick, + modifier = Modifier.testTag(SendAddressScreenTestTags.DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON), ) } } @@ -100,7 +105,8 @@ internal fun TextFieldWithPaste( backgroundColorEnabled = TangemTheme.colors.button.secondary, textColor = TangemTheme.colors.text.primary1, modifier = Modifier - .padding(start = TangemTheme.dimens.spacing8), + .padding(start = TangemTheme.dimens.spacing8) + .testTag(SendAddressScreenTestTags.DESTINATION_TAG_PASTE_BUTTON), ) } } 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 3ae4a70812..aa4707f395 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 @@ -25,7 +25,9 @@ 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.core.ui.message.DialogMessage -import com.tangem.features.staking.impl.R +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.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -34,6 +36,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,14 +55,11 @@ 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 +import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.analytics.StakingParamsInterceptor import com.tangem.features.staking.impl.analytics.utils.StakingAnalyticSender import com.tangem.features.staking.impl.navigation.InnerStakingRouter @@ -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 { @@ -173,6 +176,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 @@ -238,6 +243,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() @@ -248,7 +256,6 @@ internal class StakingModel @Inject constructor( init { subscribeOnSelectedAppCurrency() - subscribeOnBalanceHiding() subscribeOnCurrencyStatusUpdates() stateController.initializeWithUserWallet(userWallet) } @@ -298,6 +305,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, @@ -1048,60 +1058,75 @@ internal class StakingModel @Inject constructor( } private fun subscribeOnCurrencyStatusUpdates() { - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = userWalletId, - currencyId = cryptoCurrencyId, - isSingleWalletWithTokens = false, - ) - .conflate() - .distinctUntilChanged() - .filter { - value.currentStep == StakingStep.InitialInfo || isTopHeatupCase() - } - .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 || isTopHeatupCase() + }.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 || isTopHeatupCase() + } + .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 (!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, + ), + ) + } - checkForTonHeatupCase() - 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 + + checkForTonHeatupCase() + setupApprovalNeeded() + setupIsAnyTokenStaked() + checkIfSubtractAvailable() + subscribeOnActionsUpdates(status) + subscribeOnStepChanges(status) + subscribeOnBalanceHiding() } private fun subscribeOnBalanceHiding() { @@ -1109,7 +1134,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) @@ -1184,6 +1216,9 @@ internal class StakingModel @Inject constructor( userWalletProvider = Provider { userWallet }, appCurrencyProvider = Provider { appCurrency }, balancesToShowProvider = Provider { balancesToShow }, + isAccountsModeEnabled = isAccountsModeEnabled, + account = account, + isBalanceHidden = isBalanceHiddenFlow.value, ), SetConfirmationStateEmptyTransformer, ) @@ -1218,6 +1253,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/common/ConfirmData.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/ConfirmData.kt index 995f10c9ae..5ce32201dc 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/ConfirmData.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/ConfirmData.kt @@ -2,6 +2,7 @@ package com.tangem.features.swap.v2.impl.common import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM @@ -16,6 +17,7 @@ internal data class ConfirmData( val feeError: GetFeeError?, val fromCryptoCurrencyStatus: CryptoCurrencyStatus?, val toCryptoCurrencyStatus: CryptoCurrencyStatus?, + val fromAccount: Account.CryptoPortfolio?, val quote: SwapQuoteUM?, val rateType: ExpressRateType?, ) \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index c4961dabf6..ecbfea1277 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -135,6 +135,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( onDirect = { secondaryCurrencyStatus }, onReverse = { primaryCurrencyStatus }, ), + fromAccount = params.accountFlow.value, quote = amountUM?.selectedQuote, rateType = amountUM?.swapRateType, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index 58ed07d518..3602881267 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -6,6 +6,7 @@ import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.ExpressProviderType +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.swap.models.SwapDataModel @@ -64,7 +65,7 @@ internal class SwapTransactionSender @AssistedInject constructor( } } - suspend fun onCexTransaction( + private suspend fun onCexTransaction( confirmData: ConfirmData, isAmountSubtractAvailable: Boolean, onSendSuccess: (String, Long, SwapDataModel) -> Unit, @@ -74,6 +75,7 @@ internal class SwapTransactionSender @AssistedInject constructor( ) { val fromStatus = confirmData.fromCryptoCurrencyStatus ?: return val toStatus = confirmData.toCryptoCurrencyStatus ?: return + val fromAccount = confirmData.fromAccount val provider = confirmData.quote?.provider ?: return val rateType = confirmData.rateType ?: return val amountValue = confirmData.enteredAmount ?: return @@ -102,6 +104,7 @@ internal class SwapTransactionSender @AssistedInject constructor( createAndSendCexTransaction( fromAmount = fromAmount, fromStatus = fromStatus, + fromAccount = fromAccount, toStatus = toStatus, fee = confirmData.fee, provider = provider, @@ -116,6 +119,7 @@ internal class SwapTransactionSender @AssistedInject constructor( fromAmount: BigDecimal, fromStatus: CryptoCurrencyStatus, toStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, fee: Fee, provider: ExpressProvider, swapData: SwapDataModel, @@ -160,6 +164,8 @@ internal class SwapTransactionSender @AssistedInject constructor( userWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, toCryptoCurrencyStatus = toStatus, + fromAccount = fromAccount, + toAccount = null, swapDataTransactionModel = swapTransaction, provider = provider, txHash = txHash, 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/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 368cdeec5b..3f33c58822 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal interface SwapComponent : ComposableContentComponent { @@ -13,7 +14,14 @@ interface SwapComponent : ComposableContentComponent { val userWalletId: UserWalletId, val isInitialReverseOrder: Boolean = false, val screenSource: String, - ) + val tangemPayInput: TangemPayInput? = null, + ) { + data class TangemPayInput( + val cryptoAmount: BigDecimal, + val fiatAmount: BigDecimal, + val depositAddress: String, + ) + } interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index 5a76552289..046da4aa6a 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.transaction.models) implementation(projects.domain.express.models) + implementation(projects.domain.account.status) /** Data */ implementation(projects.data.common) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 67bcaffe1e..ab4f58a5df 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -8,6 +8,10 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.supplier.SingleAccountListSupplier +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.wallet.UserWallet @@ -19,11 +23,14 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn internal class DefaultSwapTransactionRepository( private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, + private val singleAccountListSupplier: SingleAccountListSupplier, + private val accountsFeatureToggles: AccountsFeatureToggles, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, networkFactory: NetworkFactory, ) : SwapTransactionRepository { @@ -40,6 +47,8 @@ internal class DefaultSwapTransactionRepository( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transaction: SavedSwapTransactionModel, ) { transaction.status?.let { @@ -73,12 +82,16 @@ internal class DefaultSwapTransactionRepository( userWalletId = userWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = fromAccount, + toAccount = toAccount, transactions = tokenTransactions, ) ?: listOf( converter.default( userWalletId = userWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = fromAccount, + toAccount = toAccount, tokenTransactions = listOf(transaction), ), ), @@ -97,7 +110,12 @@ internal class DefaultSwapTransactionRepository( flow2 = appPreferencesStore.getObjectMap( key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, ), - ) { savedTransactions, txStatuses -> + flow3 = if (accountsFeatureToggles.isFeatureEnabled) { + singleAccountListSupplier(SingleAccountListProducer.Params(userWallet.walletId)) + } else { + flowOf(null) + }, + ) { savedTransactions, txStatuses, accountList -> val currencyToTxs = savedTransactions?.filter { val isUserWallet = it.userWalletId == userWallet.walletId.stringValue @@ -115,6 +133,7 @@ internal class DefaultSwapTransactionRepository( converter.convertBack( value = it, userWallet = userWallet, + accountList = accountList, txStatuses = txStatuses, onFilter = { it.swapTxTypeDTO == SwapTxTypeDTO.Swap }, ) @@ -124,6 +143,7 @@ internal class DefaultSwapTransactionRepository( converter.convertBack( value = it, userWallet = userWallet, + accountList = accountList, txStatuses = txStatuses, ) }.orEmpty() @@ -223,10 +243,13 @@ internal class DefaultSwapTransactionRepository( fromCryptoCurrencyId == fromCurrencyId.value } + @Suppress("LongParameterList") private fun List.updateList( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transactions: List, ): List { return addOrReplace( @@ -234,6 +257,8 @@ internal class DefaultSwapTransactionRepository( userWalletId = userWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = fromAccount, + toAccount = toAccount, tokenTransactions = transactions, ), predicate = { diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index 261e5f675c..3144aa0a95 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -6,6 +6,9 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory 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.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +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 @@ -29,33 +32,37 @@ internal class SavedSwapTransactionListConverter( toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( currency = value.fromCryptoCurrency, - accountId = null, + accountId = value.fromAccount?.accountId, ), toTokensResponse = userTokensResponseFactory.createResponseToken( currency = value.toCryptoCurrency, - accountId = null, + accountId = value.toAccount?.accountId, ), transactions = value.transactions, ) fun convertBack( value: SavedSwapTransactionListModelInner, + accountList: AccountList?, userWallet: UserWallet, txStatuses: Map, onFilter: (SavedSwapTransactionModel) -> Boolean = { true }, ): SavedSwapTransactionListModel? { val fromToken = value.fromTokensResponse val toToken = value.toTokensResponse + val fromDerivationIndex = fromToken?.getDerivationIndex() + val toDerivationIndex = toToken?.getDerivationIndex() + return if (fromToken == null || toToken == null) { null } else { - val fromNetwork = createSwapTransactionNetwork(fromToken, userWallet) ?: return null + val fromNetwork = createSwapTransactionNetwork(fromToken, userWallet, fromDerivationIndex) ?: return null val fromCryptoCurrency = responseCryptoCurrenciesFactory.createCurrency( responseToken = fromToken, userWallet = userWallet, network = fromNetwork, ) ?: return null - val toNetwork = createSwapTransactionNetwork(fromToken, userWallet) ?: return null + val toNetwork = createSwapTransactionNetwork(toToken, userWallet, toDerivationIndex) ?: return null val toCryptoCurrency = responseCryptoCurrenciesFactory.createCurrency( responseToken = toToken, userWallet = userWallet, @@ -81,14 +88,25 @@ internal class SavedSwapTransactionListConverter( toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, + fromAccount = findAccountByDerivationIndex( + accountList = accountList, + derivationIndex = fromDerivationIndex, + ), + toAccount = findAccountByDerivationIndex( + accountList = accountList, + derivationIndex = toDerivationIndex, + ), ) } } + @Suppress("LongParameterList") fun default( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, tokenTransactions: List, ) = SavedSwapTransactionListModelInner( userWalletId = userWalletId.stringValue, @@ -96,13 +114,20 @@ internal class SavedSwapTransactionListConverter( toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( currency = fromCryptoCurrency, - accountId = null, + accountId = fromAccount?.accountId, + ), + toTokensResponse = userTokensResponseFactory.createResponseToken( + currency = toCryptoCurrency, + accountId = toAccount?.accountId, ), - toTokensResponse = userTokensResponseFactory.createResponseToken(currency = toCryptoCurrency, accountId = null), transactions = tokenTransactions, ) - private fun createSwapTransactionNetwork(token: UserTokensResponse.Token, userWallet: UserWallet): Network? { + private fun createSwapTransactionNetwork( + token: UserTokensResponse.Token, + userWallet: UserWallet, + accountIndex: DerivationIndex?, + ): Network? { val blockchain = Blockchain.fromNetworkId(token.networkId) ?: return null return if (token.derivationPath == null) { @@ -116,7 +141,20 @@ internal class SavedSwapTransactionListConverter( blockchain = blockchain, extraDerivationPath = token.derivationPath, userWallet = userWallet, + accountIndex = accountIndex, ) } } + + private fun findAccountByDerivationIndex( + accountList: AccountList?, + derivationIndex: DerivationIndex?, + ): Account.CryptoPortfolio? { + return accountList?.accounts?.asSequence()?.filterIsInstance() + ?.firstOrNull { it.derivationIndex == derivationIndex } + } + + private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { + return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() } + } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index b80b472729..bb9b016c41 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -10,6 +10,8 @@ import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.DefaultSwapRepository @@ -62,12 +64,16 @@ internal class SwapDataModule { appPreferencesStore: AppPreferencesStore, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, networkFactory: NetworkFactory, + singleAccountListSupplier: SingleAccountListSupplier, + accountsFeatureToggles: AccountsFeatureToggles, dispatcherProvider: CoroutineDispatcherProvider, ): SwapTransactionRepository { return DefaultSwapTransactionRepository( appPreferencesStore = appPreferencesStore, responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, networkFactory = networkFactory, + singleAccountListSupplier = singleAccountListSupplier, + accountsFeatureToggles = accountsFeatureToggles, dispatchers = dispatcherProvider, ) } 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/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt index 94742cbac9..f25e4b1c1a 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain.models.domain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal @@ -12,6 +13,8 @@ data class SavedSwapTransactionListModel( val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, val toCryptoCurrency: CryptoCurrency, + val fromAccount: Account.CryptoPortfolio?, + val toAccount: Account.CryptoPortfolio?, val transactions: List, ) 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..09dbef0286 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, @@ -63,6 +71,8 @@ interface SwapInteractor { swapData: SwapDataModel?, currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, @@ -90,6 +100,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..cab225f602 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, @@ -572,6 +714,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel?, currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, @@ -607,6 +751,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( onSwapCex( currencyToSend = currencyToSend, currencyToGet = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, amount = amountToSwapWithFee, txFee = fee, swapProvider = swapProvider, @@ -622,6 +768,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, amountToSwap = amountToSwap, ) } else { @@ -631,6 +779,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, txFee = fee, amountToSwap = amountToSwap, ) @@ -686,6 +836,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, txFee: TxFee, ): SwapTransactionState { @@ -714,6 +866,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, derivationPath = derivationPath, txData = txData, @@ -727,6 +881,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, ): SwapTransactionState { val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX @@ -743,6 +899,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, derivationPath = derivationPath, txData = compiledTransaction, @@ -756,6 +914,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, derivationPath: String?, txData: TransactionData, @@ -782,6 +942,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( storeSwapTransaction( currencyToSend = currencyToSendStatus, currencyToGet = currencyToGetStatus, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, swapProvider = provider, swapDataModel = swapData, @@ -820,6 +982,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun onSwapCex( currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, txFee: TxFee, swapProvider: SwapProvider, @@ -891,6 +1055,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( storeSwapTransaction( currencyToSend = currencyToSend, currencyToGet = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -924,6 +1090,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun storeSwapTransaction( currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -936,6 +1104,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( userWalletId = userWalletId, fromCryptoCurrency = currencyToSend.currency, toCryptoCurrency = currencyToGet.currency, + fromAccount = fromAccount, + toAccount = toAccount, transaction = SavedSwapTransactionModel( txId = swapDataModel.transaction.txId, provider = swapProvider, @@ -976,6 +1146,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 +1201,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 +1256,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 +1276,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 +1292,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 +1322,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val state = updatePermissionState( networkId = networkId, fromTokenStatus = fromToken, + fromAccount = fromAccount, swapAmount = amount, quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, @@ -1174,6 +1364,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ifLeft = { error -> createSwapErrorWith( fromToken = fromToken, + fromAccount = fromAccount, amount = amount, includeFeeInAmount = includeFeeInAmount, expressDataError = error, @@ -1184,6 +1375,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createSwapErrorWith( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, @@ -1194,6 +1386,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 +1500,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 +1540,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return produceDexSwapDataError( error = ExpressDataError.TooLargeSolanaTransactionError, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) } @@ -1384,7 +1580,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 +1611,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( produceDexSwapDataError( error = error, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) }, @@ -1422,6 +1621,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 +1630,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 +1699,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 +1714,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 +1722,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 +1769,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 +1826,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/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index ef64270e26..122d227590 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain +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.wallet.UserWallet @@ -11,10 +12,13 @@ import kotlinx.coroutines.flow.Flow interface SwapTransactionRepository { + @Suppress("LongParameterList") suspend fun storeTransaction( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transaction: SavedSwapTransactionModel, ) diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index c87d68c2df..b1c706c900 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) implementation(projects.domain.express.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) + implementation(projects.domain.visa) /** Feature modules */ implementation(projects.features.swap.domain) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt new file mode 100644 index 0000000000..f556cdf5e5 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt @@ -0,0 +1,147 @@ +package com.tangem.feature.swap.converters + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormattedCryptoAmount +import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormattedFiatAmount +import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +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.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.feature.swap.domain.models.ui.AccountSwapAvailability +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList + +internal class AccountTokenItemConverter( + private val appCurrency: AppCurrency, + private val unavailableErrorText: TextReference, + private val onItemClick: (String) -> Unit, +) : Converter { + + override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio { + return TokensListItemUM.Portfolio( + tokenItemUM = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = value.account, + onItemClick = null, + ).convert(TotalFiatBalance.Failed), + isExpanded = true, + isCollapsable = false, + tokens = value.currencyList.map { accountSwapCurrency -> + if (accountSwapCurrency.isAvailable) { + createAvailableItemConverter() + } else { + createUnavailableItemConverter() + }.convert(accountSwapCurrency.cryptoCurrencyStatus) + }.map(TokensListItemUM::Token).toPersistentList(), + ) + } + + fun createAvailableItemConverter(): TokenItemStateConverter { + return TokenItemStateConverter( + appCurrency = appCurrency, + subtitleStateProvider = { + createSubtitleState( + status = it, + isAvailable = true, + text = stringReference(value = it.currency.symbol), + ) + }, + subtitle2StateProvider = ::createSubtitle2State, + fiatAmountStateProvider = { + createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true) + }, + onItemClick = { account, currencyStatus -> onItemClick(currencyStatus.currency.id.value) }, + ) + } + + fun createUnavailableItemConverter(): TokenItemStateConverter { + return TokenItemStateConverter( + appCurrency = appCurrency, + iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) }, + titleStateProvider = { + TokenItemState.TitleState.Content( + text = stringReference(value = it.currency.name), + isAvailable = false, + ) + }, + subtitleStateProvider = { + createSubtitleState( + status = it, + isAvailable = false, + text = unavailableErrorText, + ) + }, + subtitle2StateProvider = ::createSubtitle2State, + fiatAmountStateProvider = { + createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false) + }, + ) + } + + private fun createSubtitleState( + status: CryptoCurrencyStatus, + isAvailable: Boolean, + text: TextReference, + ): TokenItemState.SubtitleState { + return when (status.value) { + CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading + else -> { + TokenItemState.SubtitleState.TextContent( + value = text, + isAvailable = isAvailable, + ) + } + } + } + + private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? { + return when (status.value) { + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> { + TokenItemState.Subtitle2State.TextContent( + text = status.getFormattedCryptoAmount(), + isFlickering = status.value.isFlickering(), + ) + } + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> null + } + } + + private fun createFiatAmountStateProvider( + status: CryptoCurrencyStatus, + appCurrency: AppCurrency, + isAvailable: Boolean, + ): TokenItemState.FiatAmountState? { + return when (status.value) { + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> { + TokenItemState.FiatAmountState.TextContent( + text = status.getFormattedFiatAmount(appCurrency = appCurrency), + isAvailable = isAvailable, + isFlickering = status.value.isFlickering(), + ) + } + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Loading, + -> null + } + } +} \ No newline at end of file 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..e9f9ec7e68 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,8 +54,10 @@ class TokensDataConverter( } } .toImmutableList(), + tokensListData = TokenListUMData.EmptyList, onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, + isBalanceHidden = isBalanceHiddenProvider(), afterSearch = group.isAfterSearch, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt new file mode 100644 index 0000000000..3dbd07cda3 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt @@ -0,0 +1,67 @@ +package com.tangem.feature.swap.converters + +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup +import com.tangem.feature.swap.models.SwapSelectTokenStateHolder +import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.presentation.R +import com.tangem.utils.Provider +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.plus +import kotlinx.collections.immutable.toPersistentList + +internal class TokensDataConverterV2( + private val onSearchEntered: (String) -> Unit, + private val onTokenSelected: (String) -> Unit, + private val tokensDataState: CurrenciesGroup, + private val isBalanceHidden: Boolean, + private val isAccountsMode: Boolean, + appCurrencyProvider: Provider, +) : Transformer { + + private val accountListItemConverter = AccountTokenItemConverter( + appCurrency = appCurrencyProvider(), + unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), + onItemClick = onTokenSelected, + ) + + override fun transform(prevState: SwapStateHolder): SwapStateHolder { + val accountList = tokensDataState.accountCurrencyList + return prevState.copy( + selectTokenState = SwapSelectTokenStateHolder( + availableTokens = persistentListOf(), + unavailableTokens = persistentListOf(), + tokensListData = if (isAccountsMode) { + TokenListUMData.AccountList( + tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), + ) + } else { + TokenListUMData.TokenList( + tokensList = persistentListOf( + TokensListItemUM.GroupTitle( + id = "available_tokens_title", + text = resourceReference(R.string.exchange_tokens_available_tokens_header), + ), + ) + accountList.flatMap { (_, currencyList) -> + currencyList.asSequence().map { accountSwapCurrency -> + if (accountSwapCurrency.isAvailable) { + accountListItemConverter.createAvailableItemConverter() + } else { + accountListItemConverter.createUnavailableItemConverter() + }.convert(accountSwapCurrency.cryptoCurrencyStatus) + }.map(TokensListItemUM::Token).toPersistentList() + }.toPersistentList(), + ) + }, + onSearchEntered = onSearchEntered, + onTokenSelected = onTokenSelected, + isBalanceHidden = isBalanceHidden, + afterSearch = tokensDataState.isAfterSearch, + ), + ) + } +} \ No newline at end of file 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..d3091f9242 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 @@ -39,6 +44,7 @@ import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions +import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase @@ -105,6 +111,10 @@ 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, + private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, ) : Model() { private val params = paramsContainer.require() @@ -113,6 +123,7 @@ internal class SwapModel @Inject constructor( private val initialCurrencyTo = params.currencyTo private val userWalletId = params.userWalletId private val isInitiallyReversed = params.isInitialReverseOrder + private val tangemPayInput = params.tangemPayInput private val userWallet by lazy { requireNotNull( @@ -133,6 +144,7 @@ internal class SwapModel @Inject constructor( actions = createUiActions(), isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), + isAccountsModeProvider = Provider { isAccountsMode }, ) private val inputNumberFormatter = @@ -158,6 +170,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 +199,57 @@ 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 && tangemPayInput == null) { + 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 = if (tangemPayInput != null) { + getTangemPayCurrencyStatusUseCase( + currency = initialCurrencyFrom, + cryptoAmount = tangemPayInput.cryptoAmount, + fiatAmount = tangemPayInput.fiatAmount, + depositAddress = tangemPayInput.depositAddress, + ) + } else { + 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 +279,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 +292,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 +342,7 @@ internal class SwapModel @Inject constructor( applyInitialTokenChoice( state = TokensDataStateExpress.EMPTY, selectedCurrency = null, + selectedAccount = null, isReverseFromTo = isReverseFromTo, ) @@ -304,6 +379,7 @@ internal class SwapModel @Inject constructor( private fun applyInitialTokenChoice( state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?, + selectedAccount: Account.CryptoPortfolio?, isReverseFromTo: Boolean, ) { // exceptional case @@ -321,14 +397,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 +426,27 @@ 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, + ) + } 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 +455,21 @@ 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, + fromAccount = fromAccount, + toAccount = toAccount, + mainTokenId = initialCurrencyFrom.id.value, ) } singleTaskScheduler.scheduleTask( modelScope, loadQuotesTask( fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, amount = amount, reduceBalanceBy = reduceBalanceBy, toProvidersList = toProvidersList, @@ -380,7 +484,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 +497,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 +517,9 @@ internal class SwapModel @Inject constructor( ) swapInteractor.findBestQuote( fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, @@ -457,6 +567,7 @@ internal class SwapModel @Inject constructor( selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + isForTangemPaySwap = tangemPayInput != null, ) if (uiState.notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }) { analyticsEventHandler.send( @@ -475,6 +586,8 @@ internal class SwapModel @Inject constructor( fromTokenStatus = fromToken, toTokenStatus = toTokenStatus, isReverseSwapPossible = isReverseSwapPossible(), + toAccount = dataState.toAccount, + isForTangemPaySwap = tangemPayInput != null, ) } is SwapState.SwapError -> { @@ -488,6 +601,8 @@ internal class SwapModel @Inject constructor( includeFeeInAmount = state.includeFeeInAmount, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + toAccount = dataState.toAccount, + isForTangemPaySwap = tangemPayInput != null, ) sendErrorAnalyticsEvent(state.error, provider) } @@ -614,6 +729,8 @@ internal class SwapModel @Inject constructor( currencyToSend = fromCurrency, currencyToGet = requireNotNull(dataState.toCryptoCurrency), amountToSwap = requireNotNull(dataState.amount), + fromAccount = dataState.fromAccount, + toAccount = dataState.toAccount, includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = fee, expressOperationType = ExpressOperationType.SWAP, @@ -774,6 +891,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 +900,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 +930,7 @@ internal class SwapModel @Inject constructor( toGroup = tokenDataState.toGroup.copy( available = available, unavailable = unavailable, + accountCurrencyList = accountCurrencyList, isAfterSearch = true, ), ) @@ -805,25 +941,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 +972,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 +996,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 +1015,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 +1044,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 +1110,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 { @@ -937,10 +1141,13 @@ internal class SwapModel @Inject constructor( amountRaw = lastAmount.value, fromToken = newFromToken.currency, minTxAmount = minTxAmount, + fromAccount = dataState.fromAccount, ) startLoadingQuotes( fromToken = newFromToken, + fromAccount = newFromAccount, toToken = newToToken, + toAccount = newToAccount, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, toProvidersList = findSwapProviders(newFromToken, newToToken), @@ -972,6 +1179,7 @@ internal class SwapModel @Inject constructor( amountRaw = lastAmount.value, fromToken = fromToken.currency, minTxAmount = minTxAmount, + fromAccount = dataState.fromAccount, ) if (toToken != null) { @@ -982,7 +1190,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 +1477,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 +1510,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..aab8a21eae 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,22 @@ 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 isBalanceHidden: Boolean, 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 +30,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/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 8b2076a065..37879acb33 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -2,10 +2,10 @@ package com.tangem.feature.swap.models import androidx.annotation.DrawableRes import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.swapStoriesScreen.SwapStoriesUM -import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.event.consumedEvent @@ -83,21 +83,21 @@ data class SwapButton( sealed interface TransactionCardType { - val header: TextReference + val accountTitleUM: AccountTitleUM? val inputError: InputError data class Inputtable( val onAmountChanged: ((String) -> Unit), val onFocusChanged: ((Boolean) -> Unit), override val inputError: InputError, - override val header: TextReference = TextReference.Res(R.string.swapping_from_title), + override val accountTitleUM: AccountTitleUM?, ) : TransactionCardType data class ReadOnly( val showWarning: Boolean = false, val onWarningClick: (() -> Unit)? = null, override val inputError: InputError = InputError.Empty, - override val header: TextReference = TextReference.Res(R.string.swapping_to_title), + override val accountTitleUM: AccountTitleUM? = null, ) : TransactionCardType sealed interface InputError { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt index 5d05e969b8..906782071d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.models +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference @@ -12,6 +13,8 @@ data class SwapSuccessStateHolder( val providerName: TextReference, val providerType: TextReference, val providerIcon: String, + val fromTitle: AccountTitleUM, + val toTitle: AccountTitleUM, val fromTokenAmount: TextReference, val toTokenAmount: TextReference, val fromTokenFiatAmount: TextReference, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt index 1be63f7e6f..9c3b0ea54d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt @@ -1,7 +1,12 @@ package com.tangem.feature.swap.preview +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.models.SwapSuccessStateHolder @@ -14,6 +19,16 @@ internal data object SwapSuccessStatePreview { providerType = TextReference.Str(ExchangeProviderType.DEX.providerName), showStatusButton = false, providerIcon = "", + fromTitle = AccountTitleUM.Account( + prefixText = stringReference("From"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(), + ), + toTitle = AccountTitleUM.Account( + prefixText = stringReference("To"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(), + ), fromTokenAmount = TextReference.Str("1 000 DAI"), toTokenAmount = TextReference.Str("1 000 MATIC"), fromTokenFiatAmount = TextReference.Str("1 000 $"), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index a21003f5f4..81772e801c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -2,6 +2,8 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM import com.tangem.common.ui.alerts.models.AlertDemoModeUM import com.tangem.common.ui.bottomsheet.permission.state.* import com.tangem.common.ui.notifications.NotificationUM @@ -18,12 +20,14 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.promo.models.StoryContent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.converters.TokensDataConverter +import com.tangem.feature.swap.converters.TokensDataConverterV2 import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType @@ -59,6 +63,7 @@ internal class StateBuilder( private val actions: UiActions, private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, + private val isAccountsModeProvider: Provider, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -86,6 +91,7 @@ internal class StateBuilder( onAmountChanged = actions.onAmountChanged, onFocusChanged = actions.onAmountSelected, inputError = TransactionCardType.InputError.Empty, + accountTitleUM = null, ), amountEquivalent = null, amountTextFieldValue = null, @@ -183,11 +189,14 @@ internal class StateBuilder( ) } + @Suppress("LongParameterList") fun createQuotesLoadingState( uiStateHolder: SwapStateHolder, fromToken: CryptoCurrency, toToken: CryptoCurrency, mainTokenId: String, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, ): SwapStateHolder { val canSelectSendToken = mainTokenId != fromToken.id.value val canSelectReceiveToken = mainTokenId != toToken.id.value @@ -199,7 +208,7 @@ internal class StateBuilder( } else { sendInputType.copy( inputError = TransactionCardType.InputError.Empty, - header = TextReference.Res(R.string.swapping_from_title), + accountTitleUM = getFromCardAccountTitle(fromAccount), ) } return uiStateHolder.copy( @@ -218,7 +227,9 @@ internal class StateBuilder( isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), + type = TransactionCardType.ReadOnly( + accountTitleUM = getToCardAccountTitle(toAccount), + ), amountTextFieldValue = null, amountEquivalent = null, token = uiStateHolder.receiveCardData.token, @@ -266,6 +277,7 @@ internal class StateBuilder( selectedFeeType: FeeType, isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, + isForTangemPaySwap: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -280,10 +292,12 @@ internal class StateBuilder( val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus val isInsufficientFunds = isInsufficientFundsCondition(quoteModel) - val insufficientFundsHeader = if (isInsufficientFunds) { - TextReference.Res(R.string.swapping_insufficient_funds) - } else { - TextReference.Res(R.string.swapping_from_title) + val fromAccount = quoteModel.fromTokenInfo.account + val toAccount = quoteModel.toTokenInfo.account + + val fromAccountTitleUM = when { + isInsufficientFunds -> AccountTitleUM.Text(TextReference.Res(R.string.swapping_insufficient_funds)) + else -> getFromCardAccountTitle(fromAccount) } val sendCardType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable) val sendInput = when (sendCardType.inputError) { @@ -299,7 +313,7 @@ internal class StateBuilder( } sendCardType.copy( inputError = error, - header = insufficientFundsHeader, + accountTitleUM = fromAccountTitleUM, ) } } @@ -321,7 +335,8 @@ internal class StateBuilder( receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( showWarning = true, - actions.onReceiveCardWarningClick, + onWarningClick = actions.onReceiveCardWarningClick, + accountTitleUM = getToCardAccountTitle(toAccount), ), amountTextFieldValue = TextFieldValue( quoteModel.toTokenInfo.tokenAmount @@ -355,7 +370,7 @@ internal class StateBuilder( enabled = getSwapButtonEnabled(notifications), onClick = actions.onSwapClick, ), - changeCardsButtonState = if (isReverseSwapPossible) { + changeCardsButtonState = if (isReverseSwapPossible && !isForTangemPaySwap) { ChangeCardsButtonState.ENABLED } else { ChangeCardsButtonState.DISABLED @@ -423,10 +438,12 @@ internal class StateBuilder( swapProvider: SwapProvider, fromToken: TokenSwapInfo, toToken: CryptoCurrencyStatus?, + toAccount: Account.CryptoPortfolio?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, + isForTangemPaySwap: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -445,9 +462,10 @@ internal class StateBuilder( selectionType = ProviderState.SelectionType.CLICK, needApplyFCARestrictions = needApplyFCARestrictions, ) + val type = TransactionCardType.ReadOnly(accountTitleUM = getToCardAccountTitle(toAccount)) val receiveCardData = toToken?.let { SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), + type = type, amountTextFieldValue = TextFieldValue( text = "0", ), @@ -463,7 +481,7 @@ internal class StateBuilder( isBalanceHidden = isBalanceHiddenProvider(), ) } ?: SwapCardState.Empty( - type = TransactionCardType.ReadOnly(), + type = type, amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", amountTextFieldValue = TextFieldValue( text = "0", @@ -484,7 +502,7 @@ internal class StateBuilder( enabled = false, onClick = actions.onSwapClick, ), - changeCardsButtonState = if (isReverseSwapPossible) { + changeCardsButtonState = if (isReverseSwapPossible && !isForTangemPaySwap) { ChangeCardsButtonState.ENABLED } else { ChangeCardsButtonState.DISABLED @@ -535,12 +553,15 @@ internal class StateBuilder( } } + @Suppress("LongParameterList") fun createQuotesEmptyAmountState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, fromTokenStatus: CryptoCurrencyStatus, toTokenStatus: CryptoCurrencyStatus?, + toAccount: Account.CryptoPortfolio?, isReverseSwapPossible: Boolean, + isForTangemPaySwap: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -560,7 +581,9 @@ internal class StateBuilder( isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), + type = TransactionCardType.ReadOnly( + accountTitleUM = getToCardAccountTitle(toAccount), + ), amountTextFieldValue = TextFieldValue("0"), amountEquivalent = emptyAmountState.zeroAmountEquivalent, token = uiStateHolder.receiveCardData.token, @@ -581,7 +604,7 @@ internal class StateBuilder( enabled = false, onClick = { }, ), - changeCardsButtonState = if (isReverseSwapPossible) { + changeCardsButtonState = if (isReverseSwapPossible && !isForTangemPaySwap) { ChangeCardsButtonState.ENABLED } else { ChangeCardsButtonState.DISABLED @@ -614,17 +637,34 @@ internal class StateBuilder( ) } + fun addTokensToStateV2( + uiState: SwapStateHolder, + tokensDataState: CurrenciesGroup, + isAccountsMode: Boolean, + ): SwapStateHolder { + return TokensDataConverterV2( + onSearchEntered = actions.onSearchEntered, + onTokenSelected = actions.onTokenSelected, + appCurrencyProvider = appCurrencyProvider, + tokensDataState = tokensDataState, + isBalanceHidden = isBalanceHiddenProvider(), + isAccountsMode = isAccountsMode, + ).transform(uiState) + } + fun createSilentLoadState(uiState: SwapStateHolder): SwapStateHolder { return uiState.copy( changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, ) } + @Suppress("LongParameterList") fun updateSwapAmount( uiState: SwapStateHolder, amountFormatted: String, amountRaw: String, fromToken: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState @@ -635,12 +675,14 @@ internal class StateBuilder( } (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.WrongAmount, - header = resourceReference(R.string.transfer_min_amount_error, wrappedList(minAmountFormatted)), + accountTitleUM = AccountTitleUM.Text( + resourceReference(R.string.transfer_min_amount_error, wrappedList(minAmountFormatted)), + ), ) ?: uiState.sendCardData.type } else { (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.Empty, - header = TextReference.Res(R.string.swapping_from_title), + accountTitleUM = getFromCardAccountTitle(fromAccount), ) ?: uiState.sendCardData.type } return uiState.copy( @@ -692,6 +734,7 @@ internal class StateBuilder( isBalanceHidden = isBalanceHidden, ) val selectTokenState = uiState.selectTokenState?.copy( + isBalanceHidden = isBalanceHidden, availableTokens = uiState.selectTokenState.availableTokens.map { when (it) { is TokenToSelectState.TokenToSelect -> { @@ -811,6 +854,8 @@ internal class StateBuilder( providerIcon = providerState.iconUrl, rate = providerState.subtitle, fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"), + fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), + toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), fromTokenFiatAmount = stringReference(fromFiatAmount), @@ -1327,6 +1372,30 @@ internal class StateBuilder( return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId) } + private fun getFromCardAccountTitle(fromAccount: Account.CryptoPortfolio?): AccountTitleUM { + return if (fromAccount != null && isAccountsModeProvider()) { + AccountTitleUM.Account( + prefixText = resourceReference(R.string.common_from), + name = fromAccount.accountName.toUM().value, + icon = fromAccount.icon.toUM(), + ) + } else { + AccountTitleUM.Text(resourceReference(R.string.swapping_from_title)) + } + } + + private fun getToCardAccountTitle(toAccount: Account.CryptoPortfolio?): AccountTitleUM { + return if (toAccount != null && isAccountsModeProvider()) { + AccountTitleUM.Account( + prefixText = resourceReference(R.string.common_to), + name = toAccount.accountName.toUM().value, + icon = toAccount.icon.toUM(), + ) + } else { + AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)) + } + } + private companion object { private const val RU_LOCALE = "ru" private const val EN_LOCALE = "en" diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 2fe84bb466..5f6b7b2be3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -383,7 +383,12 @@ private fun MainButton(state: SwapStateHolder) { // region preview private val sendCard = SwapCardState.SwapCardData( - type = TransactionCardType.Inputtable({}, {}, TransactionCardType.InputError.Empty), + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = null, + ), amountTextFieldValue = TextFieldValue(), amountEquivalent = "1 000 000", tokenIconUrl = "", 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..39625f9e1f 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 = state.isBalanceHidden, + ) + } + is TokenListUMData.TokenList -> { + tokensList( + items = list.tokensList, + isBalanceHidden = 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,7 +416,9 @@ private fun TokenScreenPreview() { state = SwapSelectTokenStateHolder( availableTokens = listOf(title, token, token, token).toImmutableList(), unavailableTokens = listOf(title, token, token, token).toImmutableList(), + tokensListData = TokenListUMData.EmptyList, afterSearch = false, + isBalanceHidden = false, onSearchEntered = {}, onTokenSelected = {}, ), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index 7e4d9649bf..ce74fd8b9a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -5,21 +5,23 @@ import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.inputrow.InputRowBestRate import com.tangem.core.ui.components.inputrow.InputRowDefault -import com.tangem.core.ui.components.inputrow.InputRowImage import com.tangem.core.ui.components.transactions.TransactionDoneTitle -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.extensions.wrappedList +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.utils.DateTimeFormatters @@ -75,26 +77,18 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad ), ) SpacerH16() - InputRowImage( - title = TextReference.Res(R.string.swapping_from_title), + SwapAmountBlock( + title = state.fromTitle, subtitle = state.fromTokenAmount, caption = state.fromTokenFiatAmount, - tokenIconState = state.fromTokenIconState ?: CurrencyIconState.Loading, - modifier = Modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action), - showNetworkIcon = true, + tokenIconState = state.fromTokenIconState, ) SpacerH16() - InputRowImage( - title = TextReference.Res(R.string.swapping_to_title), + SwapAmountBlock( + title = state.toTitle, subtitle = state.toTokenAmount, caption = state.toTokenFiatAmount, - tokenIconState = state.toTokenIconState ?: CurrencyIconState.Loading, - modifier = Modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action), - showNetworkIcon = true, + tokenIconState = state.toTokenIconState, ) SpacerH16() InputRowBestRate( @@ -117,6 +111,48 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad } } +@Composable +private fun SwapAmountBlock( + title: AccountTitleUM, + subtitle: TextReference, + caption: TextReference, + tokenIconState: CurrencyIconState?, +) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .fillMaxWidth() + .padding(12.dp), + ) { + AccountTitle(title) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CurrencyIcon( + state = tokenIconState ?: CurrencyIconState.Loading, + shouldDisplayNetwork = true, + ) + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = caption.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } +} + @Suppress("LongParameterList") @Composable private fun SwapSuccessScreenButtons( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 6443a3dd5f..a529440cd6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -34,14 +34,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM import com.tangem.core.ui.R import com.tangem.core.ui.components.* -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.core.ui.test.SwapTokenScreenTestTags import com.tangem.core.ui.utils.ImageBackgroundContrastChecker +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.TransactionCardType import kotlinx.coroutines.launch @@ -193,14 +199,22 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie } else { TangemTheme.colors.text.warning } - Text( - text = type.header.resolveReference(), - color = titleColor, - maxLines = 1, - style = TangemTheme.typography.subtitle2, - modifier = Modifier - .align(Alignment.CenterVertically), - ) + AnimatedContent( + targetState = type.accountTitleUM, + label = "", + ) { currentAccountTitle -> + if (currentAccountTitle != null) { + AccountTitle( + accountTitleUM = currentAccountTitle, + textColor = titleColor, + ) + } else { + TextShimmer( + text = stringResourceSafe(R.string.swapping_to_title), + style = TangemTheme.typography.subtitle2, + ) + } + } SpacerW16() if (balance.isNotBlank()) { AnimatedContent(targetState = balance, label = "") { @@ -559,7 +573,12 @@ private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() { @Composable private fun TransactionCardPreview() { TransactionCard( - type = TransactionCardType.Inputtable({}, {}, TransactionCardType.InputError.Empty), + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = null, + ), amountEquivalent = "1 000 000", tokenIconUrl = "", tokenCurrency = "DAI", @@ -575,7 +594,14 @@ private fun TransactionCardPreview() { @Suppress("MagicNumber") private fun TransactionCardPreviewWithPriceImpact() { TransactionCard( - type = TransactionCardType.ReadOnly(showWarning = true), + type = TransactionCardType.ReadOnly( + showWarning = true, + accountTitleUM = AccountTitleUM.Account( + prefixText = resourceReference(R.string.common_from), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(), + ), + ), amountEquivalent = "1 000 000", tokenIconUrl = "", tokenCurrency = "DAI", @@ -591,7 +617,13 @@ private fun TransactionCardPreviewWithPriceImpact() { @Suppress("MagicNumber") private fun TransactionCardPreviewWithoutPriceImpact() { TransactionCard( - type = TransactionCardType.ReadOnly(), + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Account( + prefixText = resourceReference(R.string.common_from), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(), + ), + ), amountEquivalent = "1 000 000", tokenIconUrl = "", tokenCurrency = "DAI", 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..6a0e6c5358 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -15,8 +15,14 @@ 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) + implementation(projects.core.analytics.models) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) /** Features api */ implementation(projects.features.tangempay.details.api) @@ -26,9 +32,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) @@ -38,6 +47,10 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.decompose.ext.compose) + implementation(deps.lottie.compose) + + /** AndroidX */ + implementation(deps.androidx.activity.compose) /** DI */ implementation(deps.hilt.android) 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/DefaultTangemPayDetailsComponent.kt deleted file mode 100644 index adad4cfd19..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.tangem.features.tangempay.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.slot.childSlot -import com.arkivanov.decompose.router.slot.dismiss -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child -import com.tangem.core.decompose.context.childByContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.components.NavigationBar3ButtonsScrim -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent -import com.tangem.features.tangempay.model.TangemPayDetailsModel -import com.tangem.features.tangempay.model.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, - private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, -) : AppComponentContext by appComponentContext, TangemPayDetailsComponent { - - private val model: TangemPayDetailsModel = getOrCreateModel(params = params) - - private val bottomSheetSlot = childSlot( - source = model.bottomSheetNavigation, - serializer = TangemPayDetailsNavigation.serializer(), - handleBackButton = false, - childFactory = ::bottomSheetChild, - ) - private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( - appComponentContext = child("txHistoryComponent"), - params = DefaultTangemPayTxHistoryComponent.Params(customerWalletAddress = params.config.customerWalletAddress), - ) - - @Composable - override fun Content(modifier: Modifier) { - val state by model.uiState.collectAsStateWithLifecycle() - val bottomSheet by bottomSheetSlot.subscribeAsState() - - NavigationBar3ButtonsScrim() - TangemPayDetailsScreen( - state = state, - txHistoryComponent = txHistoryComponent, - modifier = modifier, - ) - bottomSheet.child?.instance?.BottomSheet() - } - - 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 - } -} \ No newline at end of file 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..b1f3fb02be --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -0,0 +1,91 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 = componentContext, router = innerRouter), + params = params, + tokenReceiveComponentFactory = tokenReceiveComponentFactory, + ) + TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + ) + } + + private fun onChildBack() { + if (childStack.value.backStack.isEmpty()) { + router.pop() + } else { + stackNavigation.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/TangemPayAddFundsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt new file mode 100644 index 0000000000..db43708e08 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt @@ -0,0 +1,43 @@ +package com.tangem.features.tangempay.components + +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.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayTopUpData +import com.tangem.features.tangempay.model.TangemPayAddFundsModel +import com.tangem.features.tangempay.ui.TangemPayAddFundsContent +import java.math.BigDecimal + +internal class TangemPayAddFundsComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayAddFundsModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + TangemPayAddFundsContent(state = model.uiState) + } + + data class Params( + val listener: AddFundsListener, + val walletId: UserWalletId, + val cryptoBalance: BigDecimal, + val fiatBalance: BigDecimal, + val depositAddress: String, + val chainId: Int, + ) +} + +internal interface AddFundsListener { + fun onClickReceive(data: TangemPayTopUpData) + fun onClickSwap(data: TangemPayTopUpData) + fun onDismissAddFunds() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt new file mode 100644 index 0000000000..561ef1f9cf --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +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.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.model.TangemPayAddToWalletModel +import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreen + +internal class TangemPayAddToWalletComponent( + private val appComponentContext: AppComponentContext, + private val params: TangemPayDetailsContainerComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayAddToWalletModel = getOrCreateModel() + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("cardDetailsBlockComponent"), + params = TangemPayCardDetailsBlockComponent.Params(params = params), + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + BackHandler(onBack = router::pop) + TangemPayAddToWalletScreen( + state = state, + cardDetailsBlockComponent = cardDetailsBlockComponent, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt new file mode 100644 index 0000000000..35ae97b213 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt @@ -0,0 +1,31 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.security.DisableScreenshotsDisposableEffect +import com.tangem.features.tangempay.model.TangemPayChangePinModel +import com.tangem.features.tangempay.ui.TangemPayChangePinScreen + +internal class TangemPayChangePinComponent( + private val appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayChangePinModel = getOrCreateModel() + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + BackHandler(onBack = router::pop) + DisableScreenshotsDisposableEffect() + TangemPayChangePinScreen( + state = state, + onBackClick = router::pop, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt new file mode 100644 index 0000000000..d47fcd6737 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.ui.TangemPayChangePinCodeSuccessScreen + +internal class TangemPayChangePinSuccessComponent( + private val appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Composable + override fun Content(modifier: Modifier) { + BackHandler(onBack = ::backToDetails) + TangemPayChangePinCodeSuccessScreen(onClick = ::backToDetails) + } + + private fun backToDetails() { + router.popTo(route = TangemPayDetailsInnerRoute.Details) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt new file mode 100644 index 0000000000..6f3d6490e8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -0,0 +1,103 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +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.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation +import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.ui.TangemPayDetailsScreen +import com.tangem.features.tokenreceive.TokenReceiveComponent + +internal class TangemPayDetailsComponent( + private val appComponentContext: AppComponentContext, + private val params: TangemPayDetailsContainerComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TangemPayDetailsNavigation.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( + appComponentContext = child("txHistoryComponent"), + params = DefaultTangemPayTxHistoryComponent.Params( + customerWalletAddress = params.config.customerWalletAddress, + uiActions = model, + ), + ) + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("cardDetailsBlockComponent"), + params = TangemPayCardDetailsBlockComponent.Params(params = params), + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + NavigationBar3ButtonsScrim() + TangemPayDetailsScreen( + state = state, + txHistoryComponent = txHistoryComponent, + cardDetailsBlockComponent = cardDetailsBlockComponent, + modifier = modifier, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + navigation: TangemPayDetailsNavigation, + componentContext: ComponentContext, + ): 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, + ), + ) + is TangemPayDetailsNavigation.AddFunds -> TangemPayAddFundsComponent( + appComponentContext = context, + params = TangemPayAddFundsComponent.Params( + walletId = navigation.walletId, + cryptoBalance = navigation.cryptoBalance, + fiatBalance = navigation.fiatBalance, + depositAddress = navigation.depositAddress, + chainId = navigation.chainId, + listener = model, + ), + ) + } + } +} \ 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/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt new file mode 100644 index 0000000000..a044336b19 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.tangempay.components.cardDetails + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel +import com.tangem.features.tangempay.ui.CardDetailsItem +import kotlinx.coroutines.flow.StateFlow + +internal class DefaultTangemPayCardDetailsBlockComponent( + appComponentContext: AppComponentContext, + params: TangemPayCardDetailsBlockComponent.Params, +) : AppComponentContext by appComponentContext, TangemPayCardDetailsBlockComponent { + + private val model: TangemPayCardDetailsBlockModel = getOrCreateModel(params = params) + override val state: StateFlow = model.uiState + + @Composable + override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) { + CardDetailsItem(state, modifier) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/PreviewTangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/PreviewTangemPayCardDetailsBlockComponent.kt new file mode 100644 index 0000000000..861195443a --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/PreviewTangemPayCardDetailsBlockComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.tangempay.components.cardDetails + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.ui.CardDetailsItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class PreviewTangemPayCardDetailsBlockComponent( + state: TangemPayCardDetailsUM, +) : TangemPayCardDetailsBlockComponent { + + override val state: StateFlow = MutableStateFlow(state) + + @Composable + override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) { + CardDetailsItem(state, modifier) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt new file mode 100644 index 0000000000..60f012fd59 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tangempay.components.cardDetails + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import kotlinx.coroutines.flow.StateFlow + +internal interface TangemPayCardDetailsBlockComponent { + val state: StateFlow + + @Composable + fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) + + data class Params(val params: TangemPayDetailsContainerComponent.Params) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/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/PreviewTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt index 49dccbf15e..7f04f563e2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt @@ -2,8 +2,11 @@ package com.tangem.features.tangempay.components.txHistory import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState +import com.tangem.core.ui.extensions.ImageReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayTransactionState import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM import com.tangem.features.tangempay.ui.tangemPayTxHistoryItems @@ -33,47 +36,47 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor id = "signiferumque", onClick = {}, amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "16:41", title = stringReference("StarbucksStarbucksStarbucksStarbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Payment( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "12:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - isIncome = false, + icon = ImageReference.Res(R.drawable.ic_arrow_up_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Payment( id = "signiferumque", amount = "+126.20 USD", - amountColor = { TangemTheme.colors.text.accent }, + amountColor = themedColor { TangemTheme.colors.text.accent }, time = "12:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - isIncome = true, + icon = ImageReference.Res(R.drawable.ic_arrow_down_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "12:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.GroupTitle(title = "Yesterday", itemKey = "Yesterday"), @@ -81,96 +84,96 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "21:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "10:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "19:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "18:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "17:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "16:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "15:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "14:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), ), 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..de293a174d 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,9 @@ 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 com.tangem.features.tangempay.model.listener.CardDetailsEventListener +import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,7 +16,11 @@ internal interface TangemPayDetailsFeatureModule { @Binds @Singleton - fun bindTangemPayDetailsComponentFactory( - factory: DefaultTangemPayDetailsComponent.Factory, - ): TangemPayDetailsComponent.Factory + fun bindTangemPayDetailsContainerComponentFactory( + factory: DefaultTangemPayDetailsContainerComponent.Factory, + ): TangemPayDetailsContainerComponent.Factory + + @Binds + @Singleton + fun bindCardDetailsEventListener(impl: DefaultCardDetailsEventListener): CardDetailsEventListener } \ 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..2d4297613c 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 @@ -2,7 +2,12 @@ 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.TangemPayAddFundsModel +import com.tangem.features.tangempay.model.TangemPayAddToWalletModel +import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel +import com.tangem.features.tangempay.model.TangemPayChangePinModel 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 +28,29 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayTxHistoryModel::class) fun bindTangemPayTxHistoryModel(model: TangemPayTxHistoryModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayTxHistoryDetailsModel::class) + fun bindTangemPayTxHistoryDetailsModel(model: TangemPayTxHistoryDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayChangePinModel::class) + fun bindTangemPayChangePinModel(model: TangemPayChangePinModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayCardDetailsBlockModel::class) + fun bindTangemPayCardDetailsBlockModel(model: TangemPayCardDetailsBlockModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayAddToWalletModel::class) + fun bindTangemPayAddToWalletModel(model: TangemPayAddToWalletModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayAddFundsModel::class) + fun bindTangemPayAddFundsModel(model: TangemPayAddFundsModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddFundsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddFundsUM.kt new file mode 100644 index 0000000000..701f12b092 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddFundsUM.kt @@ -0,0 +1,19 @@ +package com.tangem.features.tangempay.entity + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayAddFundsUM( + val items: ImmutableList, + val dismiss: () -> Unit, + val errorMessage: MessageBottomSheetUMV2?, +) + +internal data class TangemPayAddFundsItemUM( + @DrawableRes val iconRes: Int, + val title: TextReference, + val description: TextReference, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddToWalletUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddToWalletUM.kt new file mode 100644 index 0000000000..c5aa65d1c5 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddToWalletUM.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayAddToWalletUM( + val steps: ImmutableList, + val showAddToWalletButton: Boolean, + val onBackClick: () -> Unit, + val onClickOpenWallet: () -> Unit, +) + +internal data class TangemPayAddToWalletStepItemUM( + val count: Int, + val text: TextReference, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt new file mode 100644 index 0000000000..c2ad5c05fb --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -0,0 +1,33 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.utils.CardDetailsFormatUtil +import com.tangem.utils.StringsSigns + +private const val CARD_NUMBER_SART_DIGITS_COUNT = 12 +private const val DATE_PART_LENGTH = 2 +private const val CVV_LENGTH = 3 + +internal class TangemPayCardDetailsBlockStateFactory( + private val cardNumberEnd: String, + private val onReveal: () -> Unit, + private val onCopy: (String) -> Unit, +) { + + private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT) + private val dateMasked = maskedBlock(DATE_PART_LENGTH) + private val cvvMasked = maskedBlock(CVV_LENGTH) + + fun getInitialState() = TangemPayCardDetailsUM( + number = CardDetailsFormatUtil.formatCardNumber(cardNumber = "$cardStartMasked$cardNumberEnd"), + expiry = CardDetailsFormatUtil.formatDate(month = dateMasked, year = dateMasked), + cvv = cvvMasked, + buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text), + onClick = onReveal, + onCopy = onCopy, + isHidden = true, + ) + + private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayChangePinUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayChangePinUM.kt new file mode 100644 index 0000000000..ae44a7164e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayChangePinUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.extensions.TextReference + +internal data class TangemPayChangePinUM( + val pinCode: String, + val error: TextReference?, + val onPinCodeChange: (String) -> Unit, + val submitButtonLoading: Boolean, + val submitButtonEnabled: Boolean, + val onSubmitClick: () -> Unit, +) \ 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..9e4c9f8854 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.serialization.SerializedBigDecimal +import com.tangem.domain.models.wallet.UserWalletId +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 AddFunds( + val walletId: UserWalletId, + val cryptoBalance: SerializedBigDecimal, + val fiatBalance: SerializedBigDecimal, + val depositAddress: String, + val chainId: Int, + ) : 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..102e05307a 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,54 +3,50 @@ 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.extensions.themedColor +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 import kotlinx.collections.immutable.persistentListOf -private const val CARD_NUMBER_SART_DIGITS_COUNT = 12 -private const val DATE_PART_LENGTH = 2 -private const val CVV_LENGTH = 3 - internal class TangemPayDetailsStateFactory( - private val cardNumberEnd: String, private val onBack: () -> Unit, private val onRefresh: (ShowRefreshState) -> Unit, - private val onReceive: () -> Unit, - private val onReveal: () -> Unit, - private val onCopy: (String) -> Unit, + private val onAddFunds: () -> Unit, + private val onClickChangePin: () -> Unit, + private val onClickFreezeCard: () -> Unit, ) { - private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT) - private val dateMasked = maskedBlock(DATE_PART_LENGTH) - 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), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = onClickChangePin, + ), + TangemDropdownMenuItem( + title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = onClickFreezeCard, + ), + ), + ), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = onRefresh), balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( actionButtons = persistentListOf( ActionButtonConfig( - text = resourceReference(id = R.string.common_receive), + text = resourceReference(id = R.string.tangempay_card_details_add_funds), iconResId = R.drawable.ic_arrow_down_24, - onClick = onReceive, + onClick = onAddFunds, ), ), ), - cardDetailsUM = TangemPayCardDetailsUM( - number = CardDetailsFormatUtil.formatCardNumber(cardNumber = "$cardStartMasked$cardNumberEnd"), - expiry = CardDetailsFormatUtil.formatDate(month = dateMasked, year = dateMasked), - cvv = cvvMasked, - buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text), - onClick = onReveal, - onCopy = onCopy, - isHidden = true, - ), + addToWalletBlockState = null, isBalanceHidden = false, - + addFundsEnabled = true, ) - - private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index cad1301dc5..2e59edf389 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -9,11 +9,12 @@ internal data class TangemPayDetailsUM( val topBarConfig: TangemPayDetailsTopBarConfig, val pullToRefreshConfig: PullToRefreshConfig, val balanceBlockState: TangemPayDetailsBalanceBlockState, - val cardDetailsUM: TangemPayCardDetailsUM, + val addToWalletBlockState: AddToWalletBlockState?, val isBalanceHidden: Boolean, + val addFundsEnabled: Boolean, ) -data class TangemPayCardDetailsUM( +internal data class TangemPayCardDetailsUM( val number: String = "", val expiry: String = "", val cvv: String = "", @@ -42,4 +43,9 @@ internal sealed class TangemPayDetailsBalanceBlockState { data class Error( override val actionButtons: ImmutableList, ) : TangemPayDetailsBalanceBlockState() -} \ No newline at end of file +} + +internal data class AddToWalletBlockState( + val onClick: () -> Unit, + val onClickClose: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTransactionState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTransactionState.kt index 675103d42e..f14463cd0c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTransactionState.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTransactionState.kt @@ -1,7 +1,7 @@ package com.tangem.features.tangempay.entity -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.extensions.ColorReference +import com.tangem.core.ui.extensions.ImageReference import com.tangem.core.ui.extensions.TextReference internal sealed interface TangemPayTransactionState { @@ -14,40 +14,53 @@ internal sealed interface TangemPayTransactionState { val onClick: () -> Unit val amount: String - val amountColor: @Composable (() -> Color) + val amountColor: ColorReference val title: TextReference val subtitle: TextReference + val icon: ImageReference val time: String data class Spend( override val id: String, override val onClick: () -> Unit, override val amount: String, - override val amountColor: @Composable (() -> Color), + override val amountColor: ColorReference, override val title: TextReference, override val subtitle: TextReference, + override val icon: ImageReference, override val time: String, - val iconUrl: String?, ) : Content data class Payment( override val id: String, override val onClick: () -> Unit, override val amount: String, - override val amountColor: @Composable (() -> Color), + override val amountColor: ColorReference, override val title: TextReference, override val subtitle: TextReference, + override val icon: ImageReference, override val time: String, - val isIncome: Boolean, ) : Content data class Fee( override val id: String, override val onClick: () -> Unit, override val amount: String, - override val amountColor: @Composable (() -> Color), + override val amountColor: ColorReference, override val title: TextReference, override val subtitle: TextReference, + override val icon: ImageReference, + override val time: String, + ) : Content + + data class Collateral( + override val id: String, + override val onClick: () -> Unit, + override val amount: String, + override val amountColor: ColorReference, + override val title: TextReference, + override val subtitle: TextReference, + override val icon: ImageReference, override val time: String, ) : Content } 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..be9817c6f1 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt @@ -0,0 +1,24 @@ +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 +import kotlinx.collections.immutable.ImmutableList + +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 buttons: ImmutableList, + val dismiss: () -> Unit, +) { + + data class ButtonState(val text: TextReference, val onClick: () -> Unit, val startIcon: ImageReference.Res? = null) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt new file mode 100644 index 0000000000..4caef18f17 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt @@ -0,0 +1,40 @@ +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.domain.pay.TangemPayTopUpDataFactory +import com.tangem.features.tangempay.components.TangemPayAddFundsComponent +import com.tangem.features.tangempay.entity.TangemPayAddFundsUM +import com.tangem.features.tangempay.model.transformers.TangemPayAddFundsUMConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayAddFundsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val tangemPayTopUpDataFactory: TangemPayTopUpDataFactory, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: TangemPayAddFundsUM = getInitialState() + + private fun getInitialState(): TangemPayAddFundsUM { + val data = tangemPayTopUpDataFactory.create( + depositAddress = params.depositAddress, + chainId = params.chainId, + cryptoBalance = params.cryptoBalance, + fiatBalance = params.fiatBalance, + ).getOrNull() + + return TangemPayAddFundsUMConverter(listener = params.listener).convert(data) + } + + fun onDismiss() { + params.listener.onDismissAddFunds() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddToWalletModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddToWalletModel.kt new file mode 100644 index 0000000000..0920d736e8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddToWalletModel.kt @@ -0,0 +1,59 @@ +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.navigation.Router +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayAddToWalletStepItemUM +import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM +import com.tangem.features.tangempay.utils.GoogleWalletUtil +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayAddToWalletModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val googleWalletUtil: GoogleWalletUtil, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + @Suppress("MagicNumber") + private fun getInitialState(): TangemPayAddToWalletUM { + return TangemPayAddToWalletUM( + steps = persistentListOf( + TangemPayAddToWalletStepItemUM( + count = 1, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_1), + ), + TangemPayAddToWalletStepItemUM( + count = 2, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_2), + ), + TangemPayAddToWalletStepItemUM( + count = 3, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_3), + ), + TangemPayAddToWalletStepItemUM( + count = 4, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_4), + ), + TangemPayAddToWalletStepItemUM( + count = 5, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_5), + ), + ), + showAddToWalletButton = googleWalletUtil.isWalletAvailable(), + onBackClick = router::pop, + onClickOpenWallet = googleWalletUtil::openWallet, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt new file mode 100644 index 0000000000..ab79f60985 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -0,0 +1,103 @@ +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.decompose.ui.UiMessageSender +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.pay.repository.CardDetailsRepository +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.model.listener.CardDetailsEvent +import com.tangem.features.tangempay.model.listener.CardDetailsEventListener +import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransformer +import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer +import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayCardDetailsBlockModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val cardDetailsRepository: CardDetailsRepository, + private val clipboardManager: ClipboardManager, + private val uiMessageSender: UiMessageSender, + private val cardDetailsEventListener: CardDetailsEventListener, +) : Model() { + + private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() + + private val stateFactory = TangemPayCardDetailsBlockStateFactory( + cardNumberEnd = params.params.config.cardNumberEnd, + onReveal = ::revealCardDetails, + onCopy = ::copyData, + ) + + val uiState: StateFlow + field = MutableStateFlow(stateFactory.getInitialState()) + + private val revealCardDetailsJobHolder = JobHolder() + + init { + modelScope.launch { + cardDetailsEventListener.event.collectLatest { event -> + when (event) { + CardDetailsEvent.Hide -> hideCardDetails() + CardDetailsEvent.Show -> revealCardDetails() + } + } + } + } + + private fun revealCardDetails() { + modelScope.launch { + uiState.update( + transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), + ) + cardDetailsRepository.revealCardDetails() + .onRight { + uiState.update( + transformer = DetailsRevealedStateTransformer( + details = it, + onClickHide = ::hideCardDetails, + ), + ) + } + .onLeft { + uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) + showError() + } + }.saveIn(revealCardDetailsJobHolder) + } + + fun hideCardDetails() { + modelScope.launch { + revealCardDetailsJobHolder.cancel() + uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) + } + } + + private fun showError() { + uiMessageSender.send( + SnackbarMessage(TextReference.Res(R.string.tangempay_card_details_error_text)), + ) + } + + private fun copyData(text: String) { + clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt new file mode 100644 index 0000000000..223519f8f0 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -0,0 +1,61 @@ +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.navigation.Router +import com.tangem.domain.pay.model.SetPinResult +import com.tangem.domain.pay.repository.CardDetailsRepository +import com.tangem.features.tangempay.entity.TangemPayChangePinUM +import kotlinx.coroutines.flow.* +import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayChangePinModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val cardDetailsRepository: CardDetailsRepository, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + private fun onPinCodeChange(pin: String) { + uiState.update(transformer = PinCodeChangeTransformer(newPin = pin)) + } + + private fun onClickSubmit() { + modelScope.launch { + uiState.update { it.copy(submitButtonLoading = true) } + val result = cardDetailsRepository.setPin(uiState.value.pinCode).getOrNull() + uiState.update { it.copy(submitButtonLoading = false) } + when (result) { + SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + SetPinResult.PIN_TOO_WEAK, + SetPinResult.DECRYPTION_ERROR, + SetPinResult.UNKNOWN_ERROR, + null, + -> Unit // TODO: [REDACTED_TASK_KEY] - add error handling once the requirements arrive + } + } + } + + private fun getInitialState(): TangemPayChangePinUM { + return TangemPayChangePinUM( + pinCode = "", + onPinCodeChange = ::onPinCodeChange, + onSubmitClick = ::onClickSubmit, + submitButtonEnabled = false, + submitButtonLoading = false, + error = null, + ) + } +} \ 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..c77a21d442 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 @@ -1,31 +1,36 @@ package com.tangem.features.tangempay.model 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.common.routing.AppRoute +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.core.ui.res.TangemColorPalette import com.tangem.domain.models.TokenReceiveConfig -import com.tangem.domain.models.TokenReceiveType -import com.tangem.domain.pay.DataForReceiveFactory +import com.tangem.domain.pay.TangemPayTopUpData +import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.repository.CardDetailsRepository -import com.tangem.features.tangempay.components.TangemPayDetailsComponent -import com.tangem.features.tangempay.details.impl.R +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.tangempay.components.AddFundsListener +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent 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.model.listener.CardDetailsEvent +import com.tangem.features.tangempay.model.listener.CardDetailsEventListener +import com.tangem.features.tangempay.model.transformers.DetailsAddToWalletBannerTransformer +import com.tangem.features.tangempay.model.transformers.DetailsBalanceTransformer +import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute 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 @@ -36,12 +41,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Custom token name and icon url. Will be used only for F&F. - */ -private const val TOKEN_NAME = "USDC" -private const val TOKEN_ICON_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/usd-coin.png" - @Suppress("LongParameterList") @Stable @ModelScoped @@ -50,20 +49,18 @@ internal class TangemPayDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val cardDetailsRepository: CardDetailsRepository, - private val dataForReceiveFactory: DataForReceiveFactory, - private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, -) : Model() { + private val cardDetailsEventListener: CardDetailsEventListener, +) : Model(), TangemPayTxHistoryUiActions, AddFundsListener { - private val params: TangemPayDetailsComponent.Params = paramsContainer.require() + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() private val stateFactory = TangemPayDetailsStateFactory( - cardNumberEnd = params.config.cardNumberEnd, onBack = router::pop, onRefresh = ::onRefreshSwipe, - onReceive = ::onClickReceive, - onReveal = ::revealCardDetails, - onCopy = ::copyData, + onAddFunds = ::onClickAddFunds, + onClickChangePin = ::onClickChangePin, + onClickFreezeCard = ::onClickFreezeCard, ) val uiState: StateFlow @@ -71,96 +68,126 @@ internal class TangemPayDetailsModel @Inject constructor( private val refreshStateJobHolder = JobHolder() private val fetchBalanceJobHolder = JobHolder() - private val revealCardDetailsJobHolder = JobHolder() + private val addToWalletBannerJobHolder = JobHolder() + + private var balance: TangemPayCardBalance? = null val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { + fetchAddToWalletBanner() fetchBalance() } - private fun onClickReceive() { - val depositAddress = params.config.depositAddress - if (depositAddress == null) { - showError() + private fun onClickChangePin() { + router.push(TangemPayDetailsInnerRoute.ChangePIN) + } + + private fun onClickFreezeCard() { + // TODO [REDACTED_JIRA] + } + + private fun onClickAddFunds() { + if (params.config.depositAddress == null || balance == null) { + showBottomSheetError(TangemPayDetailsErrorType.Receive) } else { - dataForReceiveFactory.getDataForReceive(depositAddress = depositAddress, chainId = params.config.chainId) - .onRight { - val config = TokenReceiveConfig( - shouldShowWarning = false, - cryptoCurrency = it.currency, - userWalletId = it.walletId, - showMemoDisclaimer = false, - receiveAddress = it.receiveAddress, - type = TokenReceiveType.Custom( - tokenName = TOKEN_NAME, - tokenIconUrl = TOKEN_ICON_URL, - fallbackTint = TangemColorPalette.Black.toArgb(), - fallbackBackground = TangemColorPalette.Meadow.toArgb(), - ), - ) - bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config)) - } - .onLeft { - val messageUM = TangemPayErrorMessageFactory.createError( - type = TangemPayDetailsErrorType.Receive, - onDismiss = bottomSheetNavigation::dismiss, - ) - bottomSheetNavigation.activate(TangemPayDetailsNavigation.Error(messageUM)) - } + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.AddFunds( + walletId = params.userWalletId, + fiatBalance = requireNotNull(balance).balance, + // Using the fiat balance as crypto balance. This will be changed when back returns crypto balance + cryptoBalance = requireNotNull(balance).balance, + depositAddress = requireNotNull(params.config.depositAddress), + chainId = params.config.chainId, + ), + ) } } private fun fetchBalance(): Job { return modelScope.launch { - val result = cardDetailsRepository.getCardBalance() - uiState.update(DetailsBalanceTransformer(result)) + val result = cardDetailsRepository.getCardBalance().onRight { balance = it } + uiState.update(DetailsBalanceTransformer(balance = result)) }.saveIn(fetchBalanceJobHolder) } + private fun fetchAddToWalletBanner() { + modelScope.launch { + val isDone = cardDetailsRepository.isAddToWalletDone().getOrNull() ?: false + uiState.update( + transformer = DetailsAddToWalletBannerTransformer( + onClickBanner = ::onClickAddToWalletBlock, + onClickCloseBanner = ::onClickCloseAddToWalletBlock, + isDone = isDone, + ), + ) + }.saveIn(addToWalletBannerJobHolder) + } + private fun onRefreshSwipe(refreshState: ShowRefreshState) { modelScope.launch { - hideCardDetails() + cardDetailsEventListener.send(CardDetailsEvent.Hide) uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value)) fetchBalance().join() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) }.saveIn(refreshStateJobHolder) } - private fun revealCardDetails() { + private fun onClickAddToWalletBlock() { + router.push(TangemPayDetailsInnerRoute.AddToWallet) + } + + private fun onClickCloseAddToWalletBlock() { modelScope.launch { + cardDetailsRepository.setAddToWalletAsDone() uiState.update( - transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), + transformer = DetailsAddToWalletBannerTransformer( + onClickBanner = ::onClickAddToWalletBlock, + onClickCloseBanner = ::onClickCloseAddToWalletBlock, + isDone = true, + ), ) - cardDetailsRepository.revealCardDetails() - .onRight { - uiState.update( - transformer = DetailsRevealedStateTransformer( - details = it, - onClickHide = ::hideCardDetails, - ), - ) - } - .onLeft { - uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) - showError() - } - }.saveIn(revealCardDetailsJobHolder) + }.saveIn(addToWalletBannerJobHolder) } - private fun hideCardDetails() { - modelScope.launch { - revealCardDetailsJobHolder.cancel() - uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) - } - } - - private fun copyData(text: String) { - clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) - } - private fun showError() { - uiMessageSender.send( - SnackbarMessage(TextReference.Res(R.string.tangempay_card_details_error_text)), + override fun onClickSwap(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + router.push( + AppRoute.Swap( + currencyFrom = data.currency, + userWalletId = data.walletId, + isInitialReverseOrder = true, + screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + tangemPayInput = AppRoute.Swap.TangemPayInput( + cryptoAmount = data.cryptoBalance, + fiatAmount = data.fiatBalance, + depositAddress = data.depositAddress, + ), + ), ) } + + override fun onClickReceive(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + val config = TokenReceiveConfig( + shouldShowWarning = false, + cryptoCurrency = data.currency, + userWalletId = data.walletId, + showMemoDisclaimer = false, + receiveAddress = data.receiveAddress, + ) + bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config)) + } + + override fun onDismissAddFunds() { + bottomSheetNavigation.dismiss() + } + + 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/listener/CardDetailsEventListener.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/CardDetailsEventListener.kt new file mode 100644 index 0000000000..94b0a1d1d7 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/CardDetailsEventListener.kt @@ -0,0 +1,17 @@ +package com.tangem.features.tangempay.model.listener + +import kotlinx.coroutines.flow.Flow + +/** + * Card details must be hidden after some UI actions from the external component + */ +internal interface CardDetailsEventListener { + + val event: Flow + + suspend fun send(event: CardDetailsEvent) +} + +internal enum class CardDetailsEvent { + Hide, Show +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/DefaultCardDetailsEventListener.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/DefaultCardDetailsEventListener.kt new file mode 100644 index 0000000000..7c7b3a3dbf --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/DefaultCardDetailsEventListener.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay.model.listener + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DefaultCardDetailsEventListener @Inject constructor() : CardDetailsEventListener { + + private val _event = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val event: Flow = _event + + override suspend fun send(event: CardDetailsEvent) { + _event.emit(event) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt new file mode 100644 index 0000000000..3fe7bddbf8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.entity.AddToWalletBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class DetailsAddToWalletBannerTransformer( + private val onClickBanner: () -> Unit, + private val onClickCloseBanner: () -> Unit, + private val isDone: Boolean, +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + return prevState.copy( + addToWalletBlockState = if (isDone) { + null + } else { + AddToWalletBlockState(onClick = onClickBanner, onClickClose = onClickCloseBanner) + }, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt index 218ff38231..867751f14a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt @@ -1,14 +1,14 @@ package com.tangem.features.tangempay.model.transformers -import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory -import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.utils.transformer.Transformer internal class DetailsHiddenStateTransformer( - private val stateFactory: TangemPayDetailsStateFactory, -) : Transformer { + private val stateFactory: TangemPayCardDetailsBlockStateFactory, +) : Transformer { - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - return prevState.copy(cardDetailsUM = stateFactory.getInitialState().cardDetailsUM) + override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM { + return stateFactory.getInitialState() } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt index b4c1968160..182248384b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt @@ -2,21 +2,19 @@ package com.tangem.features.tangempay.model.transformers import com.tangem.core.ui.extensions.TextReference import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.utils.transformer.Transformer internal class DetailsRevealProgressStateTransformer( private val onClickHide: (() -> Unit), -) : Transformer { +) : Transformer { - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM { return prevState.copy( - cardDetailsUM = prevState.cardDetailsUM.copy( - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - onClick = onClickHide, - isLoading = true, - isHidden = true, - ), + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + onClick = onClickHide, + isLoading = true, + isHidden = true, ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt index 477f82b511..654ddaa52b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt @@ -4,25 +4,23 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.pay.model.TangemPayCardDetails import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.utils.CardDetailsFormatUtil import com.tangem.utils.transformer.Transformer internal class DetailsRevealedStateTransformer( private val details: TangemPayCardDetails, private val onClickHide: (() -> Unit), -) : Transformer { +) : Transformer { - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val cardDetailsUM = TangemPayCardDetailsUM( + override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM { + return TangemPayCardDetailsUM( number = CardDetailsFormatUtil.formatCardNumber(cardNumber = details.pan), expiry = CardDetailsFormatUtil.formatDate(month = details.expirationMonth, year = details.expirationYear), cvv = details.cvv, onClick = onClickHide, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - onCopy = prevState.cardDetailsUM.onCopy, + onCopy = prevState.onCopy, isHidden = false, ) - return prevState.copy(cardDetailsUM = cardDetailsUM) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/PinCodeChangeTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/PinCodeChangeTransformer.kt new file mode 100644 index 0000000000..4b7030b2a7 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/PinCodeChangeTransformer.kt @@ -0,0 +1,27 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayChangePinUM +import com.tangem.features.tangempay.utils.PinCodeValidation +import com.tangem.utils.transformer.Transformer + +internal class PinCodeChangeTransformer( + private val newPin: String, +) : Transformer { + + override fun transform(prevState: TangemPayChangePinUM): TangemPayChangePinUM { + val valid = PinCodeValidation.validate(pinCode = newPin) + // Do not show error yet if user didn't fill all 4 spaces + val isError = PinCodeValidation.validateLength(pinCode = newPin) && !valid + return prevState.copy( + pinCode = newPin, + submitButtonEnabled = valid, + error = if (isError) { + resourceReference(R.string.visa_onboarding_pin_validation_error_message) + } else { + null + }, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt new file mode 100644 index 0000000000..1f2c4478f2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt @@ -0,0 +1,48 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.pay.TangemPayTopUpData +import com.tangem.features.tangempay.components.AddFundsListener +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayAddFundsItemUM +import com.tangem.features.tangempay.entity.TangemPayAddFundsUM +import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType +import com.tangem.features.tangempay.utils.TangemPayErrorMessageFactory +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf + +internal class TangemPayAddFundsUMConverter( + val listener: AddFundsListener, +) : Converter { + + override fun convert(value: TangemPayTopUpData?): TangemPayAddFundsUM { + return if (value == null) { + TangemPayAddFundsUM( + items = persistentListOf(), + dismiss = listener::onDismissAddFunds, + errorMessage = TangemPayErrorMessageFactory.createErrorMessage( + type = TangemPayDetailsErrorType.Receive, + ).messageBottomSheetUMV2, + ) + } else { + TangemPayAddFundsUM( + items = persistentListOf( + TangemPayAddFundsItemUM( + iconRes = R.drawable.ic_exchange_vertical_24, + title = TextReference.Res(R.string.common_exchange), + description = TextReference.Res(R.string.tangempay_card_details_swap_description), + onClick = { listener.onClickSwap(value) }, + ), + TangemPayAddFundsItemUM( + iconRes = R.drawable.ic_arrow_down_24, + title = TextReference.Res(R.string.common_receive), + description = TextReference.Res(R.string.tangempay_card_details_receive_description), + onClick = { listener.onClickReceive(value) }, + ), + ), + dismiss = listener::onDismissAddFunds, + errorMessage = null, + ) + } + } +} \ No newline at end of file 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..747cd54186 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverter.kt @@ -0,0 +1,230 @@ +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.features.tangempay.entity.TangemPayTxHistoryDetailsUM.ButtonState +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isPositive +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +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(), + buttons = value.extractButtonsState(), + 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.Collateral -> ImageReference.Res(R.drawable.ic_arrow_down_24) + 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) + } + is TangemPayTxHistoryItem.Collateral -> resourceReference(R.string.tangem_pay_deposit) + } + } + + 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) + is TangemPayTxHistoryItem.Collateral -> resourceReference(R.string.common_transfer) + } + } + + 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, + is TangemPayTxHistoryItem.Collateral, + -> { + 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 + } + } + is TangemPayTxHistoryItem.Collateral -> themedColor { TangemTheme.colors.text.accent } + } + } + + private fun TangemPayTxHistoryItem.extractLabel(): LabelUM? { + return when (this) { + is TangemPayTxHistoryItem.Fee, + is TangemPayTxHistoryItem.Payment, + is TangemPayTxHistoryItem.Collateral, + -> 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.Collateral -> 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.extractButtonsState(): ImmutableList { + return when (this.item) { + is TangemPayTxHistoryItem.Fee -> persistentListOf( + ButtonState( + text = resourceReference(R.string.tangem_pay_dispute), + onClick = this.onDisputeClick, + ), + ) + is TangemPayTxHistoryItem.Spend -> persistentListOf( + ButtonState( + text = resourceReference(R.string.tangem_pay_dispute), + onClick = this.onDisputeClick, + ), + ) + is TangemPayTxHistoryItem.Payment -> persistentListOf( + ButtonState( + text = resourceReference(R.string.tangem_pay_explore_transaction), + onClick = { this.onExplorerClick(this.item.transactionHash) }, + ), + ) + is TangemPayTxHistoryItem.Collateral -> persistentListOf( + ButtonState( + text = resourceReference(R.string.tangem_pay_explore_transaction), + startIcon = ImageReference.Res(R.drawable.ic_explore_20), + onClick = { this.onExplorerClick(this.item.transactionHash) }, + ), + ButtonState( + text = resourceReference(R.string.tangem_pay_get_help), + onClick = this.onDisputeClick, + ), + ) + } + } + + 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/model/transformers/TangemPayTxHistoryItemsConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt index efd45df7b3..0574a7d4bb 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt @@ -1,16 +1,21 @@ package com.tangem.features.tangempay.model.transformers +import com.tangem.core.ui.extensions.ImageReference +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.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.TangemPayTransactionState import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.isPositive +import com.tangem.utils.extensions.isZero import org.joda.time.DateTimeZone internal class TangemPayTxHistoryItemsConverter( @@ -21,13 +26,15 @@ internal class TangemPayTxHistoryItemsConverter( is TangemPayTxHistoryItem.Spend -> convertSpend(spend = value) is TangemPayTxHistoryItem.Payment -> convertPayment(payment = value) is TangemPayTxHistoryItem.Fee -> convertFee(fee = value) + is TangemPayTxHistoryItem.Collateral -> convertCollateral(collateral = value) } } private fun convertSpend(spend: TangemPayTxHistoryItem.Spend): TangemPayTransactionState.Content.Spend { val localDate = spend.date.withZone(DateTimeZone.getDefault()) - val amountPrefix = when (spend.status) { - TangemPayTxHistoryItem.Status.DECLINED -> "" + val amountPrefix = when { + spend.amount.isZero() -> "" + spend.status == TangemPayTxHistoryItem.Status.DECLINED -> "" else -> StringsSigns.MINUS } val amount = amountPrefix + spend.amount.format { @@ -37,7 +44,7 @@ internal class TangemPayTxHistoryItemsConverter( id = spend.id, onClick = { txHistoryUiActions.onTransactionClick(spend) }, amount = amount, - amountColor = { + amountColor = themedColor { when (spend.status) { TangemPayTxHistoryItem.Status.DECLINED -> TangemTheme.colors.text.warning else -> TangemTheme.colors.text.primary1 @@ -46,12 +53,18 @@ internal class TangemPayTxHistoryItemsConverter( title = stringReference(spend.enrichedMerchantName ?: spend.merchantName), subtitle = stringReference(spend.enrichedMerchantCategory ?: spend.merchantCategory), time = DateTimeFormatters.formatDate(localDate, DateTimeFormatters.timeFormatter), - iconUrl = spend.enrichedMerchantIconUrl, + icon = spend.enrichedMerchantIconUrl?.let(ImageReference::Url) + ?: ImageReference.Res(R.drawable.ic_category_24), ) } private fun convertPayment(payment: TangemPayTxHistoryItem.Payment): TangemPayTransactionState.Content.Payment { - val amountPrefix = if (payment.amount.isPositive()) StringsSigns.PLUS else StringsSigns.MINUS + val isIncome = payment.amount.isPositive() + val amountPrefix = when { + payment.amount.isZero() -> "" + isIncome -> StringsSigns.PLUS + else -> StringsSigns.MINUS + } val amount = amountPrefix + payment.amount.format { fiat(fiatCurrencyCode = payment.currency.currencyCode, fiatCurrencySymbol = payment.currency.symbol) } @@ -60,32 +73,49 @@ internal class TangemPayTxHistoryItemsConverter( id = payment.id, onClick = { txHistoryUiActions.onTransactionClick(payment) }, amount = amount, - amountColor = { - if (payment.amount.isPositive()) { - TangemTheme.colors.text.accent - } else { - TangemTheme.colors.text.primary1 - } + amountColor = themedColor { + if (payment.amount.isPositive()) TangemTheme.colors.text.accent else TangemTheme.colors.text.primary1 }, title = stringReference(title), subtitle = stringReference("Transfers"), time = DateTimeFormatters.formatDate(payment.date, DateTimeFormatters.timeFormatter), - isIncome = payment.amount.isPositive(), + icon = ImageReference.Res(if (isIncome) R.drawable.ic_arrow_down_24 else R.drawable.ic_arrow_up_24), ) } private fun convertFee(fee: TangemPayTxHistoryItem.Fee): TangemPayTransactionState.Content.Fee { - val amount = StringsSigns.MINUS + fee.amount.format { + val amountPrefix = if (fee.amount.isZero()) "" else StringsSigns.MINUS + val amount = amountPrefix + fee.amount.format { fiat(fiatCurrencyCode = fee.currency.currencyCode, fiatCurrencySymbol = fee.currency.symbol) } return TangemPayTransactionState.Content.Fee( id = fee.id, onClick = { txHistoryUiActions.onTransactionClick(fee) }, amount = amount, - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, title = stringReference("Fee"), subtitle = stringReference("Service fees"), + icon = ImageReference.Res(R.drawable.ic_percent_24), time = DateTimeFormatters.formatDate(fee.date, DateTimeFormatters.timeFormatter), ) } + + private fun convertCollateral( + collateral: TangemPayTxHistoryItem.Collateral, + ): TangemPayTransactionState.Content.Collateral { + val amountPrefix = if (collateral.amount.isZero()) "" else StringsSigns.PLUS + val amount = amountPrefix + collateral.amount.format { + fiat(fiatCurrencyCode = collateral.currency.currencyCode, fiatCurrencySymbol = collateral.currency.symbol) + } + return TangemPayTransactionState.Content.Collateral( + id = collateral.id, + onClick = { txHistoryUiActions.onTransactionClick(collateral) }, + amount = amount, + amountColor = themedColor { TangemTheme.colors.text.accent }, + title = resourceReference(R.string.tangem_pay_deposit), + subtitle = resourceReference(R.string.common_transfer), + icon = ImageReference.Res(R.drawable.ic_arrow_down_24), + time = DateTimeFormatters.formatDate(collateral.date, DateTimeFormatters.timeFormatter), + ) + } } \ 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..7bc24c9979 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt @@ -0,0 +1,19 @@ +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() + + @Serializable + data object ChangePINSuccess : TangemPayDetailsInnerRoute() + + @Serializable + data object AddToWallet : TangemPayDetailsInnerRoute() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/CardDetailsItem.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/CardDetailsItem.kt new file mode 100644 index 0000000000..472b10aab3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/CardDetailsItem.kt @@ -0,0 +1,19 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM + +@Composable +internal fun CardDetailsItem(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + TangemPayCardDetailsBlock( + modifier = modifier + .padding(top = TangemTheme.dimens.spacing12) + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + state = state, + ) +} \ 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/TangemPayAddFundsContent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddFundsContent.kt new file mode 100644 index 0000000000..badb84e945 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddFundsContent.kt @@ -0,0 +1,154 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2Content +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.TextReference.Res +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayAddFundsItemUM +import com.tangem.features.tangempay.entity.TangemPayAddFundsUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemPayAddFundsContent(state: TangemPayAddFundsUM) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = state.dismiss, + containerColor = TangemTheme.colors.background.tertiary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.tangempay_card_details_add_funds), + endIconRes = R.drawable.ic_close_24, + onEndClick = state.dismiss, + ) + }, + ) { + if (state.errorMessage != null) { + MessageBottomSheetV2Content(state.errorMessage) + } else { + Surface( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + shape = TangemTheme.shapes.roundedCornersXMedium, + color = TangemTheme.colors.background.primary, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), + text = resourceReference(R.string.tangempay_card_details_add_funds_subtitle).resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + state.items.forEach { + key(it.title) { + TangemPayTopUpItem(state = it) + } + } + } + } + SpacerH16() + } + } +} + +@Composable +private fun TangemPayTopUpItem(state: TangemPayAddFundsItemUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = state.onClick) + .padding(horizontal = 12.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ) + .size(36.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(id = state.iconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayAddFundsContentPreview() { + TangemThemePreview { + TangemPayAddFundsContent( + state = TangemPayAddFundsUM( + items = persistentListOf( + TangemPayAddFundsItemUM( + iconRes = R.drawable.ic_exchange_vertical_24, + title = Res(R.string.common_exchange), + description = Res(R.string.exсhange_token_description), + onClick = {}, + ), + TangemPayAddFundsItemUM( + iconRes = R.drawable.ic_arrow_down_24, + title = Res(R.string.common_receive), + description = Res(R.string.receive_token_description), + onClick = {}, + ), + ), + dismiss = {}, + errorMessage = null, + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletBlock.kt new file mode 100644 index 0000000000..50c72f534f --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletBlock.kt @@ -0,0 +1,107 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.AddToWalletBlockState + +private const val GRADIENT_START_COLOR = 0xFF252934 +private const val GRADIENT_END_COLOR = 0xFF12141E +private const val GRADIENT_OFFSET_X = 0f +private const val GRADIENT_OFFSET_Y = 80F +private const val GRADIENT_RADIUS = 200F + +@Composable +internal fun TangemPayAddToWalletBlock(state: AddToWalletBlockState, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background( + brush = Brush.radialGradient( + colors = listOf(Color(GRADIENT_START_COLOR), Color(GRADIENT_END_COLOR)), + center = Offset(GRADIENT_OFFSET_X, GRADIENT_OFFSET_Y), + radius = GRADIENT_RADIUS, + ), + ) + .clickable(onClick = state.onClick), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.img_google_wallet_48), + contentDescription = null, + modifier = Modifier.size(36.dp).clip(CircleShape), + ) + + Spacer(Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResourceSafe(R.string.tangempay_card_details_open_wallet_notification_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.constantWhite, + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = stringResourceSafe(R.string.tangempay_card_details_open_wallet_notification_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.TopEnd, + ) { + Image( + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .clickable(onClick = state.onClickClose) + .padding(4.dp) + .size(12.dp), + painter = painterResource(id = R.drawable.ic_close_24), + colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), + contentDescription = null, + ) + } + } + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewTangemPayAddToWalletBlock() { + TangemThemePreview { + TangemPayAddToWalletBlock(AddToWalletBlockState({}, {})) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt new file mode 100644 index 0000000000..249aee7ace --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -0,0 +1,192 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +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.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +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.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayAddToWalletStepItemUM +import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemPayAddToWalletScreen( + state: TangemPayAddToWalletUM, + cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + AppBarWithBackButton( + modifier = Modifier.statusBarsPadding(), + iconRes = R.drawable.ic_close_24, + onBackClick = state.onBackClick, + ) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + state = listState, + contentPadding = PaddingValues(bottom = TangemTheme.dimens.spacing16 + bottomBarHeight), + ) { + item(key = TangemPayCardDetailsUM::class.java) { + TangemPayCardDetailsBlockItem(component = cardDetailsBlockComponent, state = cardDetailsState) + } + + item(key = TangemPayAddToWalletUM::class.java) { + Text( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 24.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.tangempay_card_details_open_wallet_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + } + itemsIndexed( + items = state.steps, + key = { _, step -> "${TangemPayAddToWalletStepItemUM::class.java}${step.count}" }, + ) { idx, step -> + StepItem( + modifier = Modifier.padding(bottom = if (idx < state.steps.lastIndex) 16.dp else 0.dp), + stepNumber = step.count, + title = step.text, + ) + } + } + + SecondaryButton( + modifier = Modifier + .imePadding() + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + .fillMaxWidth(), + text = resourceReference(R.string.common_got_it).resolveReference(), + onClick = state.onBackClick, + + ) + if (state.showAddToWalletButton) { + PrimaryButton( + modifier = Modifier + .imePadding() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + text = resourceReference(R.string.tangempay_card_details_open_wallet_button).resolveReference(), + onClick = state.onClickOpenWallet, + ) + } + } +} + +@Composable +private fun StepItem(stepNumber: Int, title: TextReference, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background(color = Color.Transparent, shape = TangemTheme.shapes.roundedCorners8) + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .size(24.dp) + .background(color = TangemColorPalette.Azure, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = stepNumber.toString(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary2, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewTangemPayAddToWalletScreen() { + TangemThemePreview { + TangemPayAddToWalletScreen( + state = TangemPayAddToWalletUM( + steps = persistentListOf( + TangemPayAddToWalletStepItemUM( + count = 1, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_1), + ), + TangemPayAddToWalletStepItemUM( + count = 2, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_2), + ), + TangemPayAddToWalletStepItemUM( + count = 3, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_3), + ), + TangemPayAddToWalletStepItemUM( + count = 4, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_4), + ), + TangemPayAddToWalletStepItemUM( + count = 5, + text = resourceReference(R.string.tangempay_card_details_open_wallet_step_5), + ), + ), + showAddToWalletButton = true, + onBackClick = {}, + onClickOpenWallet = {}, + ), + cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( + TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onCopy = {}, + onClick = {}, + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt new file mode 100644 index 0000000000..091b6c0731 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -0,0 +1,164 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM + +private const val REVEAL_ANIMATION_MILLIS = 500 + +@Composable +internal fun TangemPayCardDetailsBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + val alpha = if (state.isLoading) { + val infiniteTransition = rememberInfiniteTransition() + val animation by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = tween(REVEAL_ANIMATION_MILLIS, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + ) + animation + } else { + 1f + } + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ), + ) { + CardDetailsBlockHeader(state) + CardDetailsTextContainer( + modifier = Modifier + .graphicsLayer { this.alpha = alpha } + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, top = 4.dp), + text = state.number, + onCopy = { state.onCopy(state.number) }, + isHidden = state.isHidden, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp, top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CardDetailsTextContainer( + modifier = Modifier + .graphicsLayer { this.alpha = alpha } + .weight(1f), + text = state.expiry, + onCopy = { state.onCopy(state.expiry) }, + isHidden = state.isHidden, + ) + CardDetailsTextContainer( + modifier = Modifier + .graphicsLayer { this.alpha = alpha } + .weight(1f), + text = state.cvv, + onCopy = { state.onCopy(state.cvv) }, + isHidden = state.isHidden, + ) + } + } +} + +@Composable +private fun CardDetailsBlockHeader(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier + .padding(start = 12.dp, top = 12.dp), + text = stringResourceSafe(R.string.tangempay_card_details_title), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + Text( + modifier = Modifier + .padding(top = 8.dp, end = 4.dp) + .clip(TangemTheme.shapes.roundedCornersMedium) + .clickable(onClick = state.onClick) + .padding(horizontal = 8.dp, vertical = 4.dp), + text = state.buttonText.resolveReference(), + color = TangemTheme.colors.text.accent, + style = TangemTheme.typography.body2, + ) + } +} + +@Composable +private fun CardDetailsTextContainer( + text: String, + isHidden: Boolean, + onCopy: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .heightIn(min = 48.dp) + .background(color = TangemTheme.colors.field.primary, shape = RoundedCornerShape(16.dp)), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), + text = text, + maxLines = 1, + color = if (isHidden) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1, + style = TangemTheme.typography.body2, + ) + + if (!isHidden) { + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + modifier = Modifier + .padding(end = TangemTheme.dimens.spacing8) + .size(TangemTheme.dimens.size32), + onClick = onCopy, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size24), + painter = painterResource(id = R.drawable.ic_copy_new_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlockItem.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlockItem.kt new file mode 100644 index 0000000000..01e3a91379 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlockItem.kt @@ -0,0 +1,15 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM + +@Composable +internal fun TangemPayCardDetailsBlockItem( + component: TangemPayCardDetailsBlockComponent, + state: TangemPayCardDetailsUM, + modifier: Modifier = Modifier, +) { + component.CardDetailsBlockContent(state = state, modifier = modifier) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt new file mode 100644 index 0000000000..d1c902a313 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt @@ -0,0 +1,127 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.appbar.TangemTopAppBar +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.tangempay.details.impl.R + +@Composable +internal fun TangemPayChangePinCodeSuccessScreen(onClick: () -> Unit, modifier: Modifier = Modifier) { + val composition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(R.raw.anim_confetti)) + val progress by animateLottieCompositionAsState(composition) + var showConfetti by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = resourceReference(R.string.common_done).resolveReference(), + startButton = null, + titleAlignment = Alignment.CenterHorizontally, + ) + Column( + modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SuccessContent( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + text = stringResourceSafe(R.string.common_done), + onClick = onClick, + ) + } + + if (showConfetti) { + FullScreen(notTouchable = true) { + LottieAnimation( + composition = composition, + progress = { progress }, + ) + } + } + + LaunchedEffect(Unit) { + showConfetti = true + } + + LaunchedEffect(progress == 1f) { + if (progress == 1f) { + showConfetti = false + } + } + } +} + +@Composable +private fun SuccessContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_success_blue_76), + tint = Color.Unspecified, + contentDescription = null, + modifier = Modifier + .size(76.dp), + ) + SpacerH32() + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + SpacerH12() + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_description), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(72.dp) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TangemThemePreview { + TangemPayChangePinCodeSuccessScreen(onClick = {}) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt new file mode 100644 index 0000000000..f052057177 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt @@ -0,0 +1,224 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +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.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayChangePinUM +import kotlinx.coroutines.delay + +@Composable +internal fun TangemPayChangePinScreen( + state: TangemPayChangePinUM, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + AppBarWithBackButton( + modifier = Modifier.statusBarsPadding(), + onBackClick = onBackClick, + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 48.dp) + .padding(horizontal = 36.dp) + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResourceSafe(R.string.visa_onboarding_pin_code_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + SpacerH16() + + Text( + text = stringResourceSafe(R.string.visa_onboarding_pin_code_description), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + + SpacerH(26.dp) + + PinCodeSection(state) + } + + NavigationPrimaryButton( + modifier = Modifier + .imePadding() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + primaryButton = NavigationButton( + textReference = resourceReference(R.string.common_submit), + onClick = state.onSubmitClick, + shouldShowProgress = state.submitButtonLoading, + isEnabled = state.submitButtonEnabled, + ), + ) + } +} + +@Composable +private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Modifier) { + val focusRequester = remember { FocusRequester() } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PinCode( + modifier = modifier, + value = state.pinCode, + onValueChange = state.onPinCodeChange, + focusRequester = focusRequester, + ) + + AnimatedVisibility( + visible = state.error != null, + ) { + val error = remember(this) { requireNotNull(state.error) } + Column { + SpacerH4() + Text( + text = error.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.warning, + textAlign = TextAlign.Center, + ) + } + } + } + + LaunchedEffect(Unit) { + delay(timeMillis = 300) + focusRequester.requestFocus() + } +} + +@Composable +private fun PinCode( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + numbersCount: Int = 4, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + BasicTextField( + value = value, + onValueChange = { text -> + if (text.length <= numbersCount) { + onValueChange(text) + } + }, + modifier = modifier + .focusRequester(focusRequester) + .clickable { + focusRequester.requestFocus() + keyboardController?.show() + }, + textStyle = TangemTheme.typography.h1.copy(color = Transparent), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + cursorBrush = SolidColor(Transparent), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(numbersCount) { index -> + val digit = value.getOrNull(index)?.toString() + PinDigitBox( + digit = digit, + backgroundColor = TangemTheme.colors.field.focused, + borderColor = TangemTheme.colors.stroke.primary, + textColor = TangemTheme.colors.text.primary1, + textStyle = TangemTheme.typography.h1, + ) + } + } + innerTextField() + } + }, + ) +} + +@Composable +private fun PinDigitBox( + digit: String?, + backgroundColor: Color, + borderColor: Color, + textColor: Color, + textStyle: TextStyle, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(width = 48.dp, height = 64.dp) + .background( + color = backgroundColor, + shape = RoundedCornerShape(12.dp), + ) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + if (digit != null) { + Text( + text = digit, + style = textStyle, + color = textColor, + textAlign = TextAlign.Center, + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index bd9cc78a83..e3b08338ea 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -2,20 +2,14 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.* import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -39,6 +33,8 @@ 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.TokenDetailsTopBarTestTags +import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R @@ -46,12 +42,11 @@ import com.tangem.features.tangempay.entity.* import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf -private const val REVEAL_ANIMATION_MILLIS = 500 - @Composable internal fun TangemPayDetailsScreen( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, + cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { @@ -70,6 +65,7 @@ internal fun TangemPayDetailsScreen( val listState = rememberLazyListState() val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() + val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() TangemPullToRefreshContainer( config = state.pullToRefreshConfig, @@ -94,19 +90,22 @@ internal fun TangemPayDetailsScreen( ) }, ) - item( - key = TangemPayCardDetailsUM::class.java, - content = { - TangemPayCardDetailsBlock( - modifier = modifier - .padding(top = TangemTheme.dimens.spacing12) - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - state = state.cardDetailsUM, - ) - }, - ) - + if (state.addToWalletBlockState != null) { + item( + key = AddToWalletBlockState::class.java, + content = { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + }, + ) + } + item(TangemPayCardDetailsUM::class.java) { + TangemPayCardDetailsBlockItem(component = cardDetailsBlockComponent, state = cardDetailsState) + } with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } } } @@ -218,136 +217,6 @@ private fun CryptoBalance( } // endregion -// region Card details block -@Composable -private fun TangemPayCardDetailsBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { - val alpha = if (state.isLoading) { - val infiniteTransition = rememberInfiniteTransition() - val animation by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(REVEAL_ANIMATION_MILLIS, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - ) - animation - } else { - 1f - } - Column( - modifier = modifier - .background( - color = TangemTheme.colors.background.primary, - shape = TangemTheme.shapes.roundedCornersMedium, - ), - ) { - CardDetailsBlockHeader(state) - CardDetailsTextContainer( - modifier = Modifier - .graphicsLayer { this.alpha = alpha } - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, top = 4.dp), - text = state.number, - onCopy = { state.onCopy(state.number) }, - isHidden = state.isHidden, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, bottom = 12.dp, top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - CardDetailsTextContainer( - modifier = Modifier - .graphicsLayer { this.alpha = alpha } - .weight(1f), - text = state.expiry, - onCopy = { state.onCopy(state.expiry) }, - isHidden = state.isHidden, - ) - CardDetailsTextContainer( - modifier = Modifier - .graphicsLayer { this.alpha = alpha } - .weight(1f), - text = state.cvv, - onCopy = { state.onCopy(state.cvv) }, - isHidden = state.isHidden, - ) - } - } -} - -@Composable -private fun CardDetailsBlockHeader(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { - Row( - modifier = modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - modifier = Modifier - .padding(start = 12.dp, top = 12.dp), - text = stringResourceSafe(R.string.tangempay_card_details_title), - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.subtitle2, - ) - Text( - modifier = Modifier - .padding(top = 8.dp, end = 4.dp) - .clip(TangemTheme.shapes.roundedCornersMedium) - .clickable(onClick = state.onClick) - .padding(horizontal = 8.dp, vertical = 4.dp), - text = state.buttonText.resolveReference(), - color = TangemTheme.colors.text.accent, - style = TangemTheme.typography.body2, - ) - } -} - -@Composable -private fun CardDetailsTextContainer( - text: String, - isHidden: Boolean, - onCopy: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .heightIn(min = 48.dp) - .background(color = TangemTheme.colors.field.primary, shape = RoundedCornerShape(16.dp)), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), - text = text, - maxLines = 1, - color = if (isHidden) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1, - style = TangemTheme.typography.body2, - ) - - if (!isHidden) { - Spacer(modifier = Modifier.weight(1f)) - - IconButton( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing8) - .size(TangemTheme.dimens.size32), - onClick = onCopy, - ) { - Icon( - modifier = Modifier.size(TangemTheme.dimens.size24), - painter = painterResource(id = R.drawable.ic_copy_new_24), - tint = TangemTheme.colors.icon.informative, - contentDescription = null, - ) - } - } - } -} - -// endregion - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modifier: Modifier = Modifier) { @@ -411,6 +280,16 @@ private fun TangemPayDetailsScreenPreview( txHistoryComponent = PreviewTangemPayTxHistoryComponent( txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, ), + cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( + TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + expiry = "••/••", + cvv = "•••", + onCopy = {}, + onClick = {}, + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + ), + ), ) } } @@ -432,29 +311,17 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider { - if (state.iconUrl != null) { - RemoteIcon(modifier = modifier, url = state.iconUrl) - } else { - LocalStaticIcon(modifier = modifier, id = R.drawable.ic_category_24) - } + is TangemPayTransactionState.Content -> when (val iconReference = state.icon) { + is ImageReference.Url -> RemoteIcon(modifier = modifier, url = iconReference.url) + is ImageReference.Res -> LocalStaticIcon( + modifier = modifier, + id = iconReference.resId, + iconSize = TangemTheme.dimens.size20, + ) } - is TangemPayTransactionState.Content.Fee -> LocalStaticIcon(modifier = modifier, id = R.drawable.ic_percent_24) - is TangemPayTransactionState.Content.Payment -> LocalStaticIcon( - modifier = modifier, - id = if (state.isIncome) R.drawable.ic_arrow_down_24 else R.drawable.ic_arrow_up_24, - ) - 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) } } @@ -357,7 +318,7 @@ private fun Amount(state: TangemPayTransactionState, isBalanceHidden: Boolean, m text = state.amount.orMaskWithStars(isBalanceHidden), modifier = modifier, textAlign = TextAlign.End, - color = state.amountColor(), + color = state.amountColor.resolveReference(), style = TangemTheme.typography.body2, ) } 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..4ce80b714b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUi.kt @@ -0,0 +1,277 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +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 androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SecondaryButtonIconStart +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.* +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 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@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, + ) + } + ButtonsContainer( + modifier = Modifier + .padding(vertical = 16.dp) + .fillMaxWidth(), + buttons = state.buttons, + ) + } + } +} + +@Composable +private fun ButtonsContainer( + buttons: ImmutableList, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + buttons.fastForEach { buttonState -> + if (buttonState.startIcon != null) { + SecondaryButtonIconStart( + modifier = Modifier.fillMaxWidth(), + text = buttonState.text.resolveReference(), + iconResId = buttonState.startIcon.resId, + onClick = buttonState.onClick, + ) + } else { + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + 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, + buttons = persistentListOf( + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_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, + ), + buttons = persistentListOf( + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_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, + buttons = persistentListOf( + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_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, + ), + buttons = persistentListOf( + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_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, + buttons = persistentListOf( + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_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, + buttons = persistentListOf( + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_explore_transaction), + startIcon = ImageReference.Res(R.drawable.ic_explore_20), + onClick = {}, + ), + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_get_help), + onClick = {}, + ), + ), + dismiss = {}, + ), + ), +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/GoogleWalletUtil.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/GoogleWalletUtil.kt new file mode 100644 index 0000000000..22e3cf50fe --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/GoogleWalletUtil.kt @@ -0,0 +1,40 @@ +package com.tangem.features.tangempay.utils + +import android.content.Context +import android.content.Intent +import dagger.hilt.android.qualifiers.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +private const val TAG = "GoogleWalletUtil" +private const val WALLET_PACKAGE_NAME = "com.google.android.apps.walletnfcrel" + +internal class GoogleWalletUtil @Inject constructor( + @ApplicationContext private val context: Context, +) { + private var walletIntent: Intent? = null + + fun isWalletAvailable(): Boolean = getWalletIntent() != null + + fun openWallet() { + val intent = getWalletIntent() + if (intent != null) { + context.startActivity(intent) + } + } + + private fun getWalletIntent(): Intent? { + return if (walletIntent != null) { + walletIntent + } else { + try { + context.packageManager.getLaunchIntentForPackage(WALLET_PACKAGE_NAME) + ?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + .also { walletIntent = it } + } catch (exception: Exception) { + Timber.tag(TAG).e(exception) + null + } + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PinCodeValidation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PinCodeValidation.kt new file mode 100644 index 0000000000..1656b21082 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PinCodeValidation.kt @@ -0,0 +1,32 @@ +package com.tangem.features.tangempay.utils + +internal object PinCodeValidation { + + private const val PIN_CODE_LENGTH = 4 + + fun validate(pinCode: String): Boolean { + return sequenceOf( + ::validateAllDigits, + ::validateLength, + ::validateNoRepeatedDigits, + ::validateNoConsecutiveDigits, + ).all { it(pinCode) } + } + + fun validateLength(pinCode: String): Boolean { + return pinCode.length == PIN_CODE_LENGTH + } + + private fun validateAllDigits(pinCode: String): Boolean { + return pinCode.all { it.isDigit() } + } + + private fun validateNoRepeatedDigits(pinCode: String): Boolean { + return pinCode.toSet().size > 1 + } + + private fun validateNoConsecutiveDigits(pinCode: String): Boolean { + return pinCode.zipWithNext().any { it.second != it.first + 1 } && + pinCode.zipWithNext().any { it.second != it.first - 1 } + } +} \ 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/tangempay/details/impl/src/main/res/drawable/img_google_wallet_48.xml b/features/tangempay/details/impl/src/main/res/drawable/img_google_wallet_48.xml new file mode 100644 index 0000000000..90d890d22a --- /dev/null +++ b/features/tangempay/details/impl/src/main/res/drawable/img_google_wallet_48.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/features/tangempay/onboarding/impl/build.gradle.kts b/features/tangempay/onboarding/impl/build.gradle.kts index 0b2af70dd3..a118c239b7 100644 --- a/features/tangempay/onboarding/impl/build.gradle.kts +++ b/features/tangempay/onboarding/impl/build.gradle.kts @@ -39,4 +39,7 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.timber) } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt index 01effd1f8b..4ee5eb136c 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayOnboardingComponent.kt @@ -21,13 +21,8 @@ internal class DefaultTangemPayOnboardingComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val state by model.screenState.collectAsStateWithLifecycle() - TandemPayOnboardingScreen( - modifier = modifier, - state = state, - onBackClick = model::back, - onOpenKycClick = model::openKyc, - ) + val state by model.uiState.collectAsStateWithLifecycle() + TandemPayOnboardingScreen(modifier = modifier, state = state) } @AssistedFactory diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 4b41785218..aedb40e989 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -7,12 +7,15 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState 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 @Stable @@ -22,12 +25,12 @@ internal class TangemPayOnboardingModel @Inject constructor( private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val repository: OnboardingRepository, + private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase, ) : Model() { private val params = paramsContainer.require() - - val screenState: StateFlow - field = MutableStateFlow(TangemPayOnboardingScreenState()) + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) init { modelScope.launch { @@ -37,19 +40,15 @@ internal class TangemPayOnboardingModel @Inject constructor( } is TangemPayOnboardingComponent.Params.Deeplink -> { repository.validateDeeplink(params.deeplink) - .onRight { isValid -> if (isValid) checkCustomerInfo() } + .onRight { isValid -> if (isValid) showOnboarding() } .onLeft { back() } } } } } - fun openKyc() { - router.replaceAll(AppRoute.Wallet, AppRoute.Kyc) - } - - fun back() { - router.pop() + private fun showOnboarding() { + uiState.update { it.copy(fullScreenLoading = false) } } private suspend fun checkCustomerInfo() { @@ -59,7 +58,7 @@ internal class TangemPayOnboardingModel @Inject constructor( !customerInfo.isKycApproved -> { when (params) { is TangemPayOnboardingComponent.Params.Deeplink -> - screenState.value = screenState.value.copy(fullScreenLoading = false) + uiState.value = uiState.value.copy(fullScreenLoading = false) else -> openKyc() } } @@ -68,4 +67,48 @@ internal class TangemPayOnboardingModel @Inject constructor( } .onLeft { back() } } + + private fun onGetCardClick() { + uiState.update { it.copy(buttonLoading = true) } + modelScope.launch { + val result = produceInitialDataUseCase() + if (result.isLeft()) { + Timber.e("Error producing initial data: ${result.leftOrNull()?.message}") + uiState.update { it.copy(buttonLoading = false) } + return@launch + } + + repository.getCustomerInfo() + .fold( + ifLeft = { + Timber.e("Error getCustomerInfo: ${it.errorCode}") + uiState.update { it.copy(buttonLoading = false) } + }, + ifRight = { customerInfo -> + if (customerInfo.isKycApproved) { + back() + } else { + openKyc() + } + }, + ) + } + } + + private fun openKyc() { + router.replaceAll(AppRoute.Wallet, AppRoute.Kyc) + } + + private fun back() { + router.pop() + } + + private fun getInitialState(): TangemPayOnboardingScreenState { + return TangemPayOnboardingScreenState( + fullScreenLoading = true, + buttonLoading = false, + onGetCardClick = ::onGetCardClick, + onBackClick = ::back, + ) + } } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt index 8f2605df2e..54f4140025 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt @@ -9,24 +9,21 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.core.ui.R import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero @Composable -internal fun TandemPayOnboardingScreen( - state: TangemPayOnboardingScreenState, - onOpenKycClick: () -> Unit, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, -) { +internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { Scaffold( modifier = modifier.systemBarsPadding(), topBar = { AppBarWithBackButton( modifier = Modifier.statusBarsPadding(), - onBackClick = onBackClick, + onBackClick = state.onBackClick, iconRes = R.drawable.ic_back_24, ) }, @@ -37,7 +34,7 @@ internal fun TandemPayOnboardingScreen( .padding(paddingValues) .fillMaxSize(), state = state, - onButtonClick = onOpenKycClick, + onButtonClick = state.onGetCardClick, ) }, ) @@ -46,8 +43,35 @@ internal fun TandemPayOnboardingScreen( @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewDarkTheme() { +private fun PreviewDarkTheme( + @PreviewParameter(TangemPayOnboardingScreenStateProvider::class) + state: TangemPayOnboardingScreenState, +) { TangemThemePreview { - TandemPayOnboardingScreen(state = TangemPayOnboardingScreenState(), {}, {}) + TandemPayOnboardingScreen(state = state, modifier = Modifier.fillMaxSize()) } -} \ No newline at end of file +} + +private class TangemPayOnboardingScreenStateProvider : + CollectionPreviewParameterProvider( + listOf( + TangemPayOnboardingScreenState( + fullScreenLoading = true, + buttonLoading = false, + onGetCardClick = {}, + onBackClick = {}, + ), + TangemPayOnboardingScreenState( + fullScreenLoading = false, + buttonLoading = false, + onGetCardClick = {}, + onBackClick = {}, + ), + TangemPayOnboardingScreenState( + fullScreenLoading = false, + buttonLoading = true, + onGetCardClick = {}, + onBackClick = {}, + ), + ), + ) \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt index 8745cf3db9..9cf4deb4d6 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt @@ -4,6 +4,8 @@ import javax.annotation.concurrent.Immutable @Immutable internal data class TangemPayOnboardingScreenState( - val fullScreenLoading: Boolean = true, - val buttonLoading: Boolean = false, + val fullScreenLoading: Boolean, + val buttonLoading: Boolean, + val onGetCardClick: () -> Unit, + val onBackClick: () -> Unit, ) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt index 64cf095f9c..245c9a99f6 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt @@ -9,11 +9,16 @@ internal data class AccountsUM( val onBackClick: () -> Unit, val walletSelector: WalletSelector, val accountListBottomSheetConfig: AccountListBottomSheetConfig, - val onAccountsClick: () -> Boolean, - val onFetchAccountsClick: () -> Unit, - val onClearETagClick: () -> Unit, + val buttons: ImmutableList