diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 097bcd4c84..b5b03c3345 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) @@ -160,6 +162,8 @@ dependencies { implementation(projects.domain.walletManager.models) implementation(projects.domain.yieldSupply) implementation(projects.domain.blockaid) + implementation(projects.domain.hotWallet) + implementation(projects.domain.news) implementation(projects.common) implementation(projects.common.routing) @@ -167,6 +171,7 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.core.navigation) + implementation(projects.core.abTests) implementation(projects.core.configToggles) implementation(projects.core.res) implementation(projects.core.ui) @@ -209,6 +214,7 @@ dependencies { implementation(projects.data.swap) implementation(projects.data.walletManager) implementation(projects.data.yieldSupply) + implementation(projects.data.hotWallet) /** Features */ implementation(projects.features.referral.impl) @@ -245,6 +251,8 @@ dependencies { implementation(projects.features.walletSettings.impl) implementation(projects.features.markets.api) implementation(projects.features.markets.impl) + implementation(projects.features.feed.api) + implementation(projects.features.feed.impl) implementation(projects.features.onramp.api) implementation(projects.features.onramp.impl) implementation(projects.features.onboardingV2.api) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index feebdbf578..57a2c29563 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -63,7 +63,6 @@ abstract class BaseTestCase : TestCase( private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( Manifest.permission.POST_NOTIFICATIONS, - Manifest.permission.CAMERA, ) /** 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..cb7ed9ff51 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,32 @@ 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_X_ADDRESS = "XVVzUQZFrRo3MUW3HT1WUaAfE5iNr7USr9ER3DvsKmguJRa" + const val XRP_RECIPIENT_ADDRESS = "rNeY28BPda6jp5N5oESZzd2ZN7eMZy8jNf" + const val XRP_X_RECIPIENT_ADDRESS = "XVNpatGaPNpPbiAJsQz6ZczZFt97QBbFimbtGZnxBMC7ug9" + const val XRP_X_RECIPIENT_ADDRESS_WITH_TAG = "XVNpatGaPNpPbiAJsQz6ZczZFt97QB6jeQWyCM3uCaDHVix" + 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 CHIA_RECIPIENT_ADDRESS = "xch1dz2vua2qvxpx6fufd98rxxv2n8j9t3au733xlht3fga4gk8ks5mqq5tzsz" + 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 DOGECOIN_RECIPIENT_ADDRESS = "DJQR3bdhBKcFGMHX2BkMCkrMFApNWNzr6V" + const val DOGECOIN_ADDRESS = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaqz" const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L @@ -14,4 +38,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..6bcaa775de --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -0,0 +1,141 @@ +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.* +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.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.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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, inputAmount) + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } +} + +fun BaseTestCase.openSendAddressScreen( + tokenName: String, + inputAmount: 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("Assert 'Send Address' container is displayed") { + onSendAddressScreen { container.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) { + step("Assert 'Back' button is displayed") { + onScanQrScreen { backTopAppBarButton.assertIsDisplayed() } + } + step("Assert 'Flashlight' button is displayed") { + onScanQrScreen { flashlightButton.assertIsDisplayed() } + } + step("Assert 'Gallery' button is displayed") { + onScanQrScreen { galleryButton.assertIsDisplayed() } + } + if (!emptyClipboard) { + step("Assert 'Paste from clipboard' is displayed") { + onScanQrScreen { pasteFromClipboardButton.assertIsDisplayed() } + } + } +} + +fun BaseTestCase.checkDestinationTagBlock(hint: String) { + step("Assert 'Destination Tag' title is displayed") { + onSendAddressScreen { destinationTagBlockTitle().assertIsDisplayed() } + } + step("Assert 'Destination Tag' text is displayed") { + onSendAddressScreen { destinationTagBlockText.assertIsDisplayed() } + } + step("Assert 'Destination Tag' text field hint contains text '$hint'") { + onSendAddressScreen { destinationTagTextFieldHint.assertTextContains(hint) } + } + step("Assert 'Destination Tag' text field is displayed") { + onSendAddressScreen { destinationTagTextField.assertIsDisplayed() } + } + step("Assert 'Destination Tag' caution is displayed") { + onSendAddressScreen { destinationTagBlockCaution.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkRecentAddressItem(address: String, description: String?) { + step("Assert recent address '$address' with description '$description' is displayed") { + onSendAddressScreen { + recentAddressItem(recipientAddress = address, description = description).assertIsDisplayed() + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendWarningScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendWarningScenarios.kt new file mode 100644 index 0000000000..f90275a934 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendWarningScenarios.kt @@ -0,0 +1,56 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onSendConfirmScreen +import io.github.kakaocup.compose.node.element.KNode +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkSendWarning( + 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/BuyTokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt index 4f0037cbab..aba525c895 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenDetailsPageObject.kt @@ -61,20 +61,10 @@ class BuyTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProv useUnmergedTree = true } - val tokenAmountField: KNode = child { - hasParent(withTestTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT)) - useUnmergedTree = true - } - val recommendedTitle: KNode = child { hasText(getResourceString(OnrampImplR.string.onramp_recommended_title)) } - val bestRateIcon: KNode = child { - hasTestTag(OnrampOffersBlockTestTags.BEST_RATE_ICON) - useUnmergedTree = true - } - val bestRateTitle: KNode = child { hasTestTag(OnrampOffersBlockTestTags.BEST_RATE_TITLE) useUnmergedTree = true diff --git a/app/src/androidTest/kotlin/com/tangem/screens/CameraAccessDeniedBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/CameraAccessDeniedBottomSheetPageObject.kt new file mode 100644 index 0000000000..0f4d726d49 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/CameraAccessDeniedBottomSheetPageObject.kt @@ -0,0 +1,51 @@ +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.BaseBottomSheetTestTags +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.feature.qrscanning.impl.R as QrScanningImplR + +class CameraAccessDeniedBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasText(getResourceString(QrScanningImplR.string.qr_scanner_camera_denied_title)) + useUnmergedTree = true + } + + val subtitle: KNode = child { + hasText(getResourceString(QrScanningImplR.string.qr_scanner_camera_denied_text)) + useUnmergedTree = true + } + + val settingsButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.qr_scanner_camera_denied_settings_button))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val selectFromGalleryButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.qr_scanner_camera_denied_gallery_button))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val closeButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_close))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onCameraAccessDeniedBottomSheet(function: CameraAccessDeniedBottomSheetPageObject.() -> 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/PermissionDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/PermissionDialogPageObject.kt new file mode 100644 index 0000000000..4b11d545c3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/PermissionDialogPageObject.kt @@ -0,0 +1,26 @@ +package com.tangem.screens + +import com.kaspersky.components.kautomator.component.text.UiButton +import com.kaspersky.components.kautomator.component.text.UiTextView +import com.kaspersky.components.kautomator.screen.UiScreen + +object PermissionDialogPageObject : UiScreen() { + + override val packageName = "com.android.permissioncontroller" + + val allowWhileUsingButton = UiButton { + withId(this@PermissionDialogPageObject.packageName,"permission_allow_foreground_only_button") + } + + val allowOnlyThisTimeButton = UiButton { + withId(this@PermissionDialogPageObject.packageName, "permission_allow_one_time_button") + } + + val doNotAllowButton = UiButton { + withId(this@PermissionDialogPageObject.packageName, "permission_deny_button") + } + + val permissionMessage = UiTextView { + withId(this@PermissionDialogPageObject.packageName, "permission_message") + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ScanQrPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ScanQrPageObject.kt new file mode 100644 index 0000000000..a7c3dc28ad --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ScanQrPageObject.kt @@ -0,0 +1,51 @@ +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.QrCodeScreenTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +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 ScanQrPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val backTopAppBarButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val sendTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.common_send)) + useUnmergedTree = true + } + + val flashlightButton: KNode = child { + hasTestTag(QrCodeScreenTestTags.FLASHLIGHT_BUTTON) + useUnmergedTree = true + } + + val galleryButton: KNode = child { + hasTestTag(QrCodeScreenTestTags.GALLERY_BUTTON) + useUnmergedTree = true + } + + fun scanText(tokenName: String): KNode = child { + hasText(getResourceString(R.string.send_qrcode_scan_info, tokenName)) + useUnmergedTree = true + } + + val pasteFromClipboardButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.wallet_connect_paste_from_clipboard)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onScanQrScreen(function: ScanQrPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SellPageObject.kt similarity index 50% rename from app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/SellPageObject.kt index d48c6e7e67..ee721654ec 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SellPageObject.kt @@ -3,21 +3,22 @@ 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.* 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 WalletConnectScanQrPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class SellPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { - val pasteFromClipboardButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) - hasText(getResourceString(R.string.wallet_connect_paste_from_clipboard)) + val title: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.common_sell)) useUnmergedTree = true } + } -internal fun BaseTestCase.onWalletConnectScanQrScreen(function: WalletConnectScanQrPageObject.() -> Unit) = +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..aa4b7b3647 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -3,24 +3,164 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.FooterTestTags import com.tangem.core.ui.test.SendAddressScreenTestTags +import com.tangem.core.ui.test.TopAppBarTestTags import com.tangem.features.send.v2.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText +import com.tangem.core.ui.R as CoreUiR class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { + val container: KNode = child { + hasTestTag(SendAddressScreenTestTags.CONTAINER) + useUnmergedTree = true + } + + val topAppBarTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(CoreUiR.string.common_address)) + useUnmergedTree = true + } + + val addressTextFieldTitle: KNode = child { + hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD_TITLE) + useUnmergedTree = true + } + val addressTextField: KNode = child { hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD) useUnmergedTree = true } + val addressTextFieldHint: KNode = child { + hasParent(withTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD)) + useUnmergedTree = true + } + + fun recipientNetworkCaution(network: String): KNode = child { + val expectedText = getResourceString(R.string.send_recipient_address_footer, network) + .replace("**", "") + hasText(expectedText, substring = true) + hasTestTag(FooterTestTags.FOOTER_TEXT) + useUnmergedTree = true + } + + val addressPasteButton: KNode = child { + hasTestTag(SendAddressScreenTestTags.ADDRESS_PASTE_BUTTON) + useUnmergedTree = true + } + + val qrButton: KNode = child { + hasTestTag(SendAddressScreenTestTags.QR_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 + } + + val myWalletsTitle: KNode = child { + hasText(getResourceString(CoreUiR.string.send_recipient_wallets_title)) + useUnmergedTree = true + } + + fun recentAddressItem( + recipientAddress: String, + isMyWallet: Boolean = false, + description: String? = null + ): KNode = child { + hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM) + hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON)) + hasAnyDescendant(withText(recipientAddress)) + hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT)) + useUnmergedTree = true + if (description != null) { + hasAnyDescendant(withText(description, substring = true)) + } + if (isMyWallet) { + hasAnySibling(withText(getResourceString(CoreUiR.string.send_recipient_wallets_title))) + hasAnyDescendant(withText(getResourceString(CoreUiR.string.manage_tokens_network_selector_wallet))) + } else { + hasAnySibling(withText(getResourceString(CoreUiR.string.send_recent_transactions))) + hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TRANSACTION_ICON)) + } + } + + 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 destinationTagTextFieldHint: KNode = child { + hasParent(withTestTag(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, + CoreUiR.string.send_recipient_memo_footer_v2_highlighted + ), + substring = true + ) + useUnmergedTree = true + } + + 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 } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt index 5821029a67..505b43899c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -2,36 +2,60 @@ 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)) + val closeButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) useUnmergedTree = true } + val sendButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.common_send))) + useUnmergedTree = true + } - val minimumSendAmountErrorTitle: KNode = child { + val primaryAmount: KNode = child { + hasTestTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT) + useUnmergedTree = true + } + + 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 +64,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/BuyTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt index da13bb553c..a618a5b1dc 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt @@ -70,7 +70,7 @@ class BuyTokenTest : BaseTestCase() { val otherCurrenciesTitle = "Other currencies" val australianDollar = "AUD" val fiatAmount = "1" - val tokenAmount = "~488.24938338 POL" + val tokenAmount = "POL 488.24938338" val scenarioName = "payment_methods" step("Reset WireMock scenario '$scenarioName'") { @@ -98,10 +98,8 @@ class BuyTokenTest : BaseTestCase() { step("Write fiat amount = '$fiatAmount'") { onBuyTokenDetailsScreen { fiatAmountTextField.performTextInput(fiatAmount) } } - step("Assert token amount = '$tokenAmount'") { - onBuyTokenDetailsScreen { - tokenAmountField.assertTextContains(tokenAmount) - } + step("Assert offer token amount = '$tokenAmount'") { + onBuyTokenDetailsScreen { offerTokenAmount.assertTextContains(tokenAmount) } } step("Fiat currency icon is displayed") { onBuyTokenDetailsScreen { fiatCurrencyIcon.assertIsDisplayed() } @@ -129,10 +127,8 @@ class BuyTokenTest : BaseTestCase() { fiatAmountTextField.assertTextContains(australianDollar + fiatAmount) } } - step("Assert token amount = '$tokenAmount'") { - onBuyTokenDetailsScreen { - tokenAmountField.assertTextContains(tokenAmount) - } + step("Assert offer token amount = '$tokenAmount'") { + onBuyTokenDetailsScreen { offerTokenAmount.assertTextContains(tokenAmount) } } } } @@ -145,7 +141,7 @@ class BuyTokenTest : BaseTestCase() { val tokenTitle = "Polygon" val euro = "EUR" val fiatAmount = "1" - val tokenAmount = "~488.24938338 POL" + val tokenAmount = "POL 488.24938338" val scenarioName = "payment_methods" step("Reset WireMock scenario '$scenarioName'") { @@ -182,18 +178,12 @@ class BuyTokenTest : BaseTestCase() { step("Assert fiat amount = '$fiatAmount'") { onBuyTokenDetailsScreen { fiatAmountTextField.assertTextContains(euro + fiatAmount) } } - step("Assert token amount = '$tokenAmount'") { - onBuyTokenDetailsScreen { tokenAmountField.assertTextContains(tokenAmount) } - } - step("Click on 'Continue' button") { - onBuyTokenDetailsScreen { continueButton.clickWithAssertion() } + step("Assert offer token amount = '$tokenAmount'") { + onBuyTokenDetailsScreen { offerTokenAmount.assertTextContains(tokenAmount) } } step("Assert 'Recommended' title is displayed") { onBuyTokenDetailsScreen { recommendedTitle.clickWithAssertion() } } - step("Assert 'Best rate' icon is displayed") { - onBuyTokenDetailsScreen { bestRateIcon.assertIsDisplayed() } - } step("Assert 'Best rate' title is displayed") { onBuyTokenDetailsScreen { bestRateTitle.assertIsDisplayed() } } @@ -316,7 +306,7 @@ class BuyTokenTest : BaseTestCase() { val paymentMethod = "Invoice Revolut Pay" val fiatAmount = "1" val providerNameMercuryo = "Mercuryo" - val tokenAmount = "~488.24938338 POL" + val tokenAmount = "POL 488.24938338" val scenarioName = "payment_methods" step("Reset WireMock scenario '$scenarioName'") { @@ -344,11 +334,8 @@ class BuyTokenTest : BaseTestCase() { step("Write fiat amount = '$fiatAmount'") { onBuyTokenDetailsScreen { fiatAmountTextField.performTextInput(fiatAmount) } } - step("Assert token amount = '$tokenAmount'") { - onBuyTokenDetailsScreen { tokenAmountField.assertTextContains(tokenAmount) } - } - step("Click on 'Continue' button") { - onBuyTokenDetailsScreen { continueButton.clickWithAssertion() } + step("Assert offer token amount = '$tokenAmount'") { + onBuyTokenDetailsScreen { offerTokenAmount.assertTextContains(tokenAmount) } } step("Assert 'Provider block' is displayed") { onBuyTokenDetailsScreen { providerName.assertIsDisplayed() } @@ -365,9 +352,6 @@ class BuyTokenTest : BaseTestCase() { step("Assert 'Provider' bottom sheet subtitle is displayed") { onSelectProviderBottomSheet { subtitle.assertIsDisplayed() } } - step("Assert 'Best rate' icon is displayed") { - onSelectProviderBottomSheet { bestRateIcon.assertIsDisplayed() } - } step("Assert 'Best rate' title is displayed") { onSelectProviderBottomSheet { bestRateTitle.assertIsDisplayed() } } @@ -406,7 +390,7 @@ class BuyTokenTest : BaseTestCase() { val invoiceRevolutPay = "Invoice Revolut Pay" val sepa = "Sepa" val fiatAmount = "1" - val tokenAmount = "~488.24938338 POL" + val tokenAmount = "POL 488.24938338" val scenarioName = "payment_methods" step("Reset WireMock scenario '$scenarioName'") { @@ -434,11 +418,8 @@ class BuyTokenTest : BaseTestCase() { step("Write fiat amount = '$fiatAmount'") { onBuyTokenDetailsScreen { fiatAmountTextField.performTextInput(fiatAmount) } } - step("Assert token amount = '$tokenAmount'") { - onBuyTokenDetailsScreen { tokenAmountField.assertTextContains(tokenAmount) } - } - step("Click on 'Continue' button") { - onBuyTokenDetailsScreen { continueButton.clickWithAssertion() } + step("Assert offer token amount = '$tokenAmount'") { + onBuyTokenDetailsScreen { offerTokenAmount.assertTextContains(tokenAmount) } } step("Assert 'Provider block' is displayed") { onBuyTokenDetailsScreen { providerName.assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 210063dedb..9d75140152 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 @@ -37,7 +36,11 @@ class FeedbackTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { + synchronizeAddresses() + } step("Click 'More' button on TopBar") { + waitForIdle() onTopBar { moreButton.clickWithAssertion() } } step("Click 'Contact support' button") { @@ -73,7 +76,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 +133,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/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt index 60dd5885f1..ae35d70e6b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -1,5 +1,6 @@ package com.tangem.tests +import android.Manifest import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion @@ -8,7 +9,7 @@ import com.tangem.common.utils.setClipboardText import com.tangem.scenarios.* import com.tangem.screens.onWalletConnectBottomSheet import com.tangem.screens.onWalletConnectDetailsBottomSheet -import com.tangem.screens.onWalletConnectScanQrScreen +import com.tangem.screens.onScanQrScreen import com.tangem.screens.onWalletConnectScreen import com.tangem.wallet.BuildConfig import dagger.hilt.android.testing.HiltAndroidTest @@ -196,8 +197,14 @@ class WalletConnectTest : BaseTestCase() { val dAppName = "React App" val context = device.context val deepLinkUri = getWcUri() + val packageName = BuildConfig.APPLICATION_ID + val permissionName = Manifest.permission.CAMERA - setupHooks().run { + setupHooks( + additionalBeforeSection = { + device.uiDevice.executeShellCommand("pm grant $packageName $permissionName") + }, + ).run { step("Set URI to clipboard") { setClipboardText(context, deepLinkUri) } @@ -214,7 +221,7 @@ class WalletConnectTest : BaseTestCase() { onWalletConnectScreen { newConnectionButton.performClick() } } step("CLick 'Paste from clipboard' button") { - onWalletConnectScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } + onScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } } step("Check 'Wallet Connect' bottom sheet") { waitForIdle() 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..fd03d3bd1e 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 @@ -18,6 +23,7 @@ import org.junit.Test @HiltAndroidTest class MainScreenActionButtonsTest : BaseTestCase() { + @ApiEnv(ApiEnvConfig(ApiConfig.ID.MoonPay, ApiEnvironment.PROD)) @AllureId("79") @DisplayName("Action buttons (long tap): validate UI") @Test @@ -121,7 +127,6 @@ class MainScreenActionButtonsTest : BaseTestCase() { @Test fun clickOnBuyButtonTest() { val tokenTitle = "Bitcoin" - val tokenSymbol = "BTC" setupHooks().run { step("Open 'Main Screen'") { @@ -160,12 +165,6 @@ class MainScreenActionButtonsTest : BaseTestCase() { 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() } - } } } @@ -281,41 +280,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 +318,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 +367,251 @@ 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" + + 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() } + } + } + } + + @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/MyWalletsBlockTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/MyWalletsBlockTest.kt new file mode 100644 index 0000000000..f26e567935 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/MyWalletsBlockTest.kt @@ -0,0 +1,142 @@ +package com.tangem.tests.send.addressScreen + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.DOGECOIN_ADDRESS +import com.tangem.common.constants.TestConstants.ETHEREUM_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openSendAddressScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +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 MyWalletsBlockTest : BaseTestCase() { + private val txHistoryScenarioName = "dogecoin_tx_history" + + @AllureId("4579") + @DisplayName("Send (address screen): click on 'My wallet address'") + @Test + fun clickOnMyWalletAddressTest() { + val tokenName = "Dogecoin" + val sendAmount = "1" + val txHistoryScenarioState = "OutgoingTransaction" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(txHistoryScenarioName) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Click on 'My wallet' address") { + onSendAddressScreen { + recentAddressItem(recipientAddress = DOGECOIN_ADDRESS, isMyWallet = true).performClick() + } + } + step("Assert recipient address is displayed on confirm screen") { + onSendConfirmScreen { recipientAddress(DOGECOIN_ADDRESS).assertIsDisplayed() } + } + } + } + + @AllureId("4599") + @DisplayName("Send (address screen): 'My wallets' block is not displayed for account networks") + @Test + fun myWalletsBlockForAccountNetworksNotDisplayedTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Assert 'My wallets' title is not displayed") { + onSendAddressScreen { myWalletsTitle.assertIsNotDisplayed() } + } + step("Assert 'My wallets' item is not displayed") { + onSendAddressScreen { + recentAddressItem( + recipientAddress = ETHEREUM_ADDRESS, + isMyWallet = true + ).assertIsNotDisplayed() + } + } + } + } + + @AllureId("4737") + @DisplayName("Send (address screen): assert 'My wallet address is correct'") + @Test + fun checkMyWalletsBlockTest() { + val tokenName = "Dogecoin" + val sendAmount = "1" + val txHistoryScenarioState = "OutgoingTransaction" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(txHistoryScenarioName) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Assert 'My wallets' title is displayed") { + onSendAddressScreen { myWalletsTitle.assertIsDisplayed() } + } + step("Assert 'My wallet' address is displayed") { + onSendAddressScreen { + recentAddressItem(recipientAddress = DOGECOIN_ADDRESS, isMyWallet = true).performClick() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt new file mode 100644 index 0000000000..1a169ead0b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt @@ -0,0 +1,355 @@ +package com.tangem.tests.send.addressScreen + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.DOGECOIN_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.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.* +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +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 RecentBlockTest : BaseTestCase() { + private val txHistoryScenarioName = "dogecoin_tx_history" + private val recentTransactionAmount1 = "DOGE 0.0192" + private val recentTransactionAmount2 = "DOGE 0.10" + private val recentTransactionAmount3 = "DOGE 0.005" + + @AllureId("4005") + @DisplayName("Send (address screen): check address history") + @Test + fun sendAddressHistoryTest() { + val tokenName = "Dogecoin" + val sendAmount = "1" + val txHistoryScenarioState = "OutgoingTransaction" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(txHistoryScenarioName) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Click on previous address") { + onSendAddressScreen { recentAddressItem(DOGECOIN_ADDRESS).performClick() } + } + step("Assert recipient address is displayed") { + onSendConfirmScreen { recipientAddress(DOGECOIN_ADDRESS).assertIsDisplayed() } + } + step("Click on top bar 'Close' button") { + waitForIdle() + onSendConfirmScreen { closeButton.clickWithAssertion() } + } + step("Assert 'Send Address' screen is displayed") { + waitForIdle() + onSendAddressScreen { container.assertIsDisplayed() } + } + step("Assert address text field is displayed") { + onSendAddressScreen { addressTextField.assertIsDisplayed() } + } + step("Assert address text field contains correct recipient address") { + onSendAddressScreen { addressTextField.assertTextContains(DOGECOIN_ADDRESS) } + } + step("Assert 'Recent' title is not 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("4597") + @DisplayName("Send (address screen): check 'Recent' block, transaction history doesn't supported") + @Test + fun recentBlockTransactionHistoryDoesNotSupportedTest() { + val tokenName = "Polkadot" + val sendAmount = "1" + + 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("Assert 'Send Address' container is displayed") { + onSendAddressScreen { container.assertIsDisplayed() } + } + step("Assert 'Recent' title is not displayed") { + onSendAddressScreen { recentAddressesTitle.assertIsNotDisplayed() } + } + } + } + + @AllureId("4596") + @DisplayName("Send (address screen): check 'Recent' block, transaction history is empty") + @Test + fun recentBlockTransactionHistoryEmptyTest() { + val tokenName = "Dogecoin" + val sendAmount = "1" + val txHistoryScenarioState = "Empty" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(txHistoryScenarioName) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Assert 'Recent' title is not displayed") { + onSendAddressScreen { recentAddressesTitle.assertIsNotDisplayed() } + } + step("Assert recent address item is not displayed") { + onSendAddressScreen { recentAddressItem(DOGECOIN_ADDRESS).assertIsNotDisplayed() } + } + } + } + + @AllureId("4598") + @DisplayName("Send (address screen): check 'Recent' block, transaction history does not load") + @Test + fun recentBlockTransactionHistoryDoesNotLoadTest() { + val tokenName = "Dogecoin" + val sendAmount = "1" + val txHistoryScenarioState = "Error" + val scenarioState = "Dogecoin" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(txHistoryScenarioName) + } + ).run { + 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("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Assert 'Recent' title is not displayed") { + onSendAddressScreen { recentAddressesTitle.assertIsNotDisplayed() } + } + step("Assert recent address item is not displayed") { + onSendAddressScreen { recentAddressItem(DOGECOIN_ADDRESS).assertIsNotDisplayed() } + } + } + } + + @AllureId("4572") + @DisplayName("Send (address screen): check 'Recent' block for >10 transactions") + @Test + fun checkRecentBlockMoreThanTenTransactionsTest() { + val tokenName = "Dogecoin" + val sendAmount = "1" + val txHistoryScenarioState = "11OutgoingTransactions" + val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq" + val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(txHistoryScenarioName) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Assert 'Recent' title is displayed") { + onSendAddressScreen { recentAddressesTitle.assertIsDisplayed() } + } + step("Check recent address item №1") { + checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1) + } + step("Check recent address item №2") { + checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2) + } + step("Check recent address item №3") { + checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2) + } + step("Check recent address item №4") { + checkRecentAddressItem(address = recipientAddressBase + "c", description = recentTransactionAmount2) + } + step("Check recent address item №5") { + checkRecentAddressItem(address = recipientAddressBase + "d", description = recentTransactionAmount2) + } + step("Check recent address item №6") { + checkRecentAddressItem(address = recipientAddressBase + "e", description = recentTransactionAmount2) + } + step("Swipe up") { + waitForIdle() + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f) + } + step("Check recent address item №7") { + checkRecentAddressItem(address = recipientAddressBase + "f", description = recentTransactionAmount2) + } + step("Check recent address item №8") { + checkRecentAddressItem(address = recipientAddressBase + "g", description = recentTransactionAmount2) + } + step("Check recent address item №9") { + checkRecentAddressItem(address = recipientAddressBase + "o", description = recentTransactionAmount2) + } + step("Check recent address item №10") { + checkRecentAddressItem(address = recipientAddressBase + "p", description = recentTransactionAmount2) + } + step("Assert recent address item №11 is not displayed") { + onSendAddressScreen { recentAddressItem(recipientAddressBase + "a").assertIsNotDisplayed() } + } + step("Click on recent address item №1") { + onSendAddressScreen { recentAddressItem(DOGECOIN_ADDRESS).performClick() } + } + step("Assert recipient address is displayed on 'Send Confirm' screen") { + onSendConfirmScreen { recipientAddress(DOGECOIN_ADDRESS).assertIsDisplayed() } + } + } + } + + @AllureId("4676") + @DisplayName("Send (address screen): check 'Recent' block for <10 transactions") + @Test + fun checkRecentBlockLessThanTenTransactionsTest() { + val tokenName = "Dogecoin" + val sendAmount = "1" + val txHistoryScenarioState = "2OutgoingTransactions" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(txHistoryScenarioName) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Assert 'Recent' title is displayed") { + onSendAddressScreen { recentAddressesTitle.assertIsDisplayed() } + } + step("Check recent address item №1") { + checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1) + } + step("Check recent address item №2") { + checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount3) + } + step("Click on recent address item") { + onSendAddressScreen { recentAddressItem(DOGECOIN_ADDRESS).performClick() } + } + step("Assert recipient address is displayed on 'Send Confirm' screen") { + onSendConfirmScreen { recipientAddress(DOGECOIN_ADDRESS).assertIsDisplayed() } + } + } + } +} \ 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..572fbce81c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt @@ -0,0 +1,629 @@ +package com.tangem.tests.send.addressScreen + +import android.Manifest +import androidx.compose.ui.test.hasText +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.XRP_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.XRP_X_ADDRESS +import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS_WITH_TAG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.clearClipboard +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setClipboardText +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.* +import com.tangem.wallet.BuildConfig +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("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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + 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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + 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("4009") + @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) } + } + } + } + + @AllureId("4574") + @DisplayName("Send (address screen): check scan QR code screen") + @Test + fun checkQrCodeScreenTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + val packageName = BuildConfig.APPLICATION_ID + val permissionName = Manifest.permission.CAMERA + + setupHooks( + additionalBeforeSection = { + device.uiDevice.executeShellCommand("pm grant $packageName $permissionName") + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Click on 'QR' button") { + onSendAddressScreen { qrButton.clickWithAssertion() } + } + step("Check 'Scan QR code' screen") { + checkScanQrScreen() + } + } + } + + @AllureId("4576") + @DisplayName("Send (address screen): give camera permission") + @Test + fun giveCameraPermissionScreenTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + + setupHooks().run { + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Click on 'QR' button") { + onSendAddressScreen { qrButton.clickWithAssertion() } + } + step("Assert 'While using app' button is displayed") { + waitForIdle() + PermissionDialogPageObject { allowWhileUsingButton.isDisplayed() } + } + step("Assert 'Allow only this time' button is displayed") { + PermissionDialogPageObject { allowOnlyThisTimeButton.isDisplayed() } + } + step("Assert 'Don't allow' button is displayed") { + PermissionDialogPageObject { doNotAllowButton.isDisplayed() } + } + step("CLick on 'While using app' button") { + PermissionDialogPageObject { allowWhileUsingButton.click() } + } + step("Check 'Scan QR code' screen") { + checkScanQrScreen() + } + } + } + + @AllureId("4578") + @DisplayName("Send (address screen): don't give camera permission") + @Test + fun doNotGiveCameraPermissionScreenTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + + setupHooks().run { + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Click on 'QR' button") { + onSendAddressScreen { qrButton.clickWithAssertion() } + } + step("Click on 'Don't allow' button") { + waitForIdle() + PermissionDialogPageObject { doNotAllowButton.click() } + } + step("Assert 'Camera access denied' bottom sheet title is displayed") { + waitForIdle() + onCameraAccessDeniedBottomSheet { title.assertIsDisplayed() } + } + step("Assert 'Camera access denied' bottom sheet subtitle is displayed") { + onCameraAccessDeniedBottomSheet { subtitle.assertIsDisplayed() } + } + step("Assert 'Settings' button is displayed") { + onCameraAccessDeniedBottomSheet { settingsButton.assertIsDisplayed() } + } + step("Assert 'Select from the gallery' button is displayed") { + onCameraAccessDeniedBottomSheet { selectFromGalleryButton.assertIsDisplayed() } + } + step("Assert 'Close' button is displayed") { + onCameraAccessDeniedBottomSheet { closeButton.assertIsDisplayed() } + } + step("Click on 'Close' button") { + onCameraAccessDeniedBottomSheet { closeButton.performClick() } + } + step("Assert 'Address' screen is displayed") { + onSendAddressScreen { container.assertIsDisplayed() } + } + } + } + + @AllureId("4590") + @DisplayName("Send (address screen): enter 'r' address (XRP)") + @Test + fun xrpEnterRAddressTest() { + val tokenName = "XRP Ledger" + val sendAmount = "1" + val xrpRecipientAddress = XRP_RECIPIENT_ADDRESS + val userTokensScenarioName = "user_tokens_api" + val userTokensScenarioState = "XRP" + val optionalText = getResourceString(R.string.send_optional_field) + + 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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(xrpRecipientAddress) } + } + step("Check 'Destination tag' block") { + checkDestinationTagBlock(hint = optionalText) + } + } + } + + @AllureId("4591") + @DisplayName("Send (address screen): enter 'X' address without tag (XRP)") + @Test + fun xrpEnterXAddressWithoutTagTest() { + val tokenName = "XRP Ledger" + val sendAmount = "1" + val xrpRecipientAddress = XRP_X_RECIPIENT_ADDRESS + val userTokensScenarioName = "user_tokens_api" + val userTokensScenarioState = "XRP" + val tagAlreadyIncluded = getResourceString(R.string.send_additional_field_already_included) + + 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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(xrpRecipientAddress) } + } + step("Check 'Destination tag' block") { + checkDestinationTagBlock(hint = tagAlreadyIncluded) + } + step("Assert 'Next' button is enabled") { + onSendAddressScreen { nextButton.assertIsEnabled() } + } + } + } + + @AllureId("4592") + @DisplayName("Send (address screen): enter 'X' address with tag (XRP)") + @Test + fun xrpEnterXAddressWithTagTest() { + val tokenName = "XRP Ledger" + val sendAmount = "1" + val xrpRecipientAddress = XRP_X_RECIPIENT_ADDRESS_WITH_TAG + val userTokensScenarioName = "user_tokens_api" + val userTokensScenarioState = "XRP" + val tagAlreadyIncluded = getResourceString(R.string.send_additional_field_already_included) + + 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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(xrpRecipientAddress) } + } + step("Check 'Destination tag' block") { + checkDestinationTagBlock(hint = tagAlreadyIncluded) + } + step("Assert 'Next' button is enabled") { + onSendAddressScreen { nextButton.assertIsEnabled() } + } + } + } + + @AllureId("4594") + @DisplayName("Send (address screen): enter own address (XRP)") + @Test + fun xrpEnterOwnXAddressTest() { + val tokenName = "XRP Ledger" + val sendAmount = "1" + val walletAddress = XRP_X_ADDRESS + val userTokensScenarioName = "user_tokens_api" + val userTokensScenarioState = "XRP" + val sameAsWalletAddress = getResourceString(R.string.send_error_address_same_as_wallet) + + 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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(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("4593") + @DisplayName("Send (address screen): enter tag before address (XRP)") + @Test + fun xrpEnterTagBeforeAddressTest() { + val tokenName = "XRP Ledger" + val sendAmount = "1" + val xrpRecipientAddress = XRP_X_RECIPIENT_ADDRESS + val destinationTag = "12345" + val emptyText = "" + val userTokensScenarioName = "user_tokens_api" + val userTokensScenarioState = "XRP" + val tagAlreadyIncluded = getResourceString(R.string.send_additional_field_already_included) + + 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("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Type destination tag in input text field") { + onSendAddressScreen { destinationTagTextField.performTextReplacement(destinationTag) } + } + step("Type X address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(xrpRecipientAddress) } + } + step("Destination tag text field doesn't contains destination tag: '$destinationTag'") { + onSendAddressScreen { destinationTagTextField.assert (!hasText(destinationTag)) } + } + step("Destination tag text field cleared") { + onSendAddressScreen { destinationTagTextField.assertTextEquals(emptyText) } + } + step("Check 'Destination tag' block") { + checkDestinationTagBlock(hint = tagAlreadyIncluded) + } + step("Assert 'Next' button is enabled") { + onSendAddressScreen { nextButton.assertIsEnabled() } + } + } + } + + @AllureId("4567") + @DisplayName("Send (address screen): check address field") + @Test + fun checkSendAddressScreenTest() { + val tokenName = "Ethereum" + val sendAmount = "1" + val hint = getResourceString(R.string.send_enter_address_field_ens) + val recipient = getResourceString(R.string.send_recipient) + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Address' screen") { + openSendAddressScreen(tokenName, sendAmount) + } + step("Assert 'Address' screen title is displayed") { + onSendAddressScreen { topAppBarTitle.assertIsDisplayed() } + } + step("Assert address text field title is displayed") { + onSendAddressScreen { addressTextFieldTitle.assertTextContains(recipient) } + } + step("Assert address text field is displayed") { + onSendAddressScreen { addressTextField.assertIsDisplayed() } + } + step("Assert address text field hint contains text '$hint'") { + onSendAddressScreen { addressTextFieldHint.assertTextContains(hint) } + } + step("Assert 'QR' button is displayed") { + onSendAddressScreen { qrButton.assertIsDisplayed() } + } + step("Assert 'Paste' button is displayed") { + onSendAddressScreen { addressPasteButton.assertIsDisplayed() } + } + step("Assert recipient network caution is displayed for network '$tokenName'") { + onSendAddressScreen { recipientNetworkCaution(tokenName).assertIsDisplayed() } + } + step("Assert 'Next' button is disabled") { + onSendAddressScreen { nextButton.assertIsNotEnabled() } + } + } + } +} \ 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..6ecfda055b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt @@ -0,0 +1,309 @@ +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.constants.TestConstants.WAIT_UNTIL_TIMEOUT +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +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 { + flakySafely(WAIT_UNTIL_TIMEOUT) { + refreshButton.clickWithAssertion() + refreshButton.assertDoesNotExist() + } + } + } + step("Check network fee block") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + 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/ChiaWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/ChiaWarningsTest.kt new file mode 100644 index 0000000000..3d41a5502f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/ChiaWarningsTest.kt @@ -0,0 +1,162 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.CHIA_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.common.utils.setWireMockScenarioState +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.Ignore +import org.junit.Test + +@HiltAndroidTest +@Ignore("[REDACTED_JIRA]") +class ChiaWarningsTest : BaseTestCase() { + private val tokenName = "Chia Network" + private val mockStateName = "Chia" + private val amountToSendUsingLessThanUTXOLimit = "20" + private val amountToSendUsingMoreThanUTXOLimit = "51" + private val amountToSendUsingExactlyUTXOLimit = "50" + private val utxoLimitAmount = "50" + + private val chiaUTXOScenarioName = "chia_get_coin_records" + private val moreThanLimitState = "more_than_50_android" + + private val warningTitle = getResourceString(R.string.send_notification_transaction_limit_title) + private val warningMessage = getResourceString( + R.string.send_notification_transaction_limit_text, + tokenName, utxoLimitAmount, utxoLimitAmount + ) + + @AllureId("4226") + @DisplayName("Warnings: check warning, when sending less than 50 utxo") + @Test + fun checkWarningWhenSendingLessThanUTXOLimit() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$chiaUTXOScenarioName' to state: '$moreThanLimitState'") { + setWireMockScenarioState(scenarioName = chiaUTXOScenarioName, state = moreThanLimitState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$amountToSendUsingLessThanUTXOLimit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToSendUsingLessThanUTXOLimit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CHIA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'UTXO limit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4227") + @DisplayName("Warnings: check warning is not displayed, when sending exactly 50 utxo") + @Test + fun checkWarningWhenSendingEqualToUTXOLimit() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$chiaUTXOScenarioName' to state: '$moreThanLimitState'") { + setWireMockScenarioState(scenarioName = chiaUTXOScenarioName, state = moreThanLimitState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$amountToSendUsingExactlyUTXOLimit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToSendUsingExactlyUTXOLimit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CHIA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'UTXO limit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4228") + @DisplayName("Warnings: check warning is not displayed, when sending more than 50 utxo") + @Test + fun checkWarningWhenSendingMoreThanUTXOLimit() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$chiaUTXOScenarioName' to state: '$moreThanLimitState'") { + setWireMockScenarioState(scenarioName = chiaUTXOScenarioName, state = moreThanLimitState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName, mockStateName) + } + step("Type '$amountToSendUsingMoreThanUTXOLimit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToSendUsingMoreThanUTXOLimit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CHIA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'UTXO limit warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt new file mode 100644 index 0000000000..5156609bb7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt @@ -0,0 +1,183 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.DOGECOIN_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.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 DogecoinWarningsTest : BaseTestCase() { + private val tokenName = "Dogecoin" + private val amountToLeaveLessThanDust = "5.78654978" + private val amountToLeaveMoreThanDust = "5.7" + private val amountGreaterThanDust = "0.02" + private val amountLessThanDust = "0.005" + private val dustAmount = "DOGE 0.01" + private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title) + private val invalidAmountMessage = getResourceString( + R.string.send_notification_invalid_minimum_amount_text, + dustAmount, dustAmount + ) + + @AllureId("4213") + @DisplayName("Warnings: warning is displayed, if after send balance is less than dust amount (Dogecoin)") + @Test + fun warningIsDisplayedWhenLeaveLessThanDust() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveLessThanDust' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveLessThanDust) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage + ) + } + } + } + + @AllureId("4214") + @DisplayName("Warnings: warning is NOT displayed, if after send balance is more than dust amount (Dogecoin)") + @Test + fun warningIsNotDisplayedWhenLeaveMoreThanDust() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveMoreThanDust' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveMoreThanDust) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_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("4211") + @DisplayName("Warnings: warning is NOT displayed, when sending more than dust amount (Dogecoin)") + @Test + fun warningIsNotDisplayedWhenSendingMoreThanDust() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountGreaterThanDust' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountGreaterThanDust) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_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("4212") + @DisplayName("Warnings: warning is displayed, when sending less than dust amount (Dogecoin)") + @Test + fun warningIsDisplayedWhenSendingLessThanDust() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountLessThanDust' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountLessThanDust) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt new file mode 100644 index 0000000000..98b623c535 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt @@ -0,0 +1,160 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.KASPA_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.common.utils.setWireMockScenarioState +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 KaspaWarningsTest : BaseTestCase() { + private val tokenName = "Kaspa" + private val amountToSendUsingLessThanUTXOLimit = "8.0" + private val amountToSendUsingMoreThanUTXOLimit = "8.7" + private val amountToSendUsingExactlyUTXOLimit = "8.19968" + private val utxoLimitAmount = "84" + private val availableToSendAmount = "KAS 8.19968" + + private val kaspaUTXOScenarioName = "kaspa_utxo" + private val moreThanLimitState = "more_than_84_android" + + private val warningTitle = getResourceString(R.string.send_notification_transaction_limit_title) + private val warningMessage = getResourceString( + R.string.send_notification_transaction_limit_text, + tokenName, utxoLimitAmount, availableToSendAmount + ) + + @AllureId("4223") + @DisplayName("Warnings: check kaspa utxo warning, when sending less than 84 utxo") + @Test + fun checkWarningWhenSendingLessThanUTXOLimit() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$moreThanLimitState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = moreThanLimitState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToSendUsingLessThanUTXOLimit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToSendUsingLessThanUTXOLimit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'UTXO limit warning' is NOT displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4225") + @DisplayName("Warnings: check kaspa utxo warning, when sending more than 84 utxo") + @Test + fun checkWarningWhenSendingMoreThanUTXOLimit() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$moreThanLimitState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = moreThanLimitState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToSendUsingMoreThanUTXOLimit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToSendUsingMoreThanUTXOLimit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'UTXO limit warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage + ) + } + } + } + + @AllureId("4224") + @DisplayName("Warnings: check kaspa utxo warning, when sending exactly 84 utxo") + @Test + fun checkWarningWhenSendingEqualToUTXOLimit() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$moreThanLimitState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = moreThanLimitState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToSendUsingExactlyUTXOLimit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToSendUsingExactlyUTXOLimit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'UTXO limit warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } +} 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..60e77f7ace 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,9 @@ + + + @@ -310,4 +313,8 @@ + + + + diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 7098dad4f2..59dd05efbe 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -5,7 +5,9 @@ import com.tangem.TangemSdkLogger import com.tangem.blockchainsdk.BlockchainSDKFactory import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter +import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.filter.OneTimeEventFilter +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.decompose.di.GlobalUiMessageSender @@ -151,4 +153,8 @@ interface ApplicationEntryPoint { fun getHotWalletFeatureToggles(): HotWalletFeatureToggles fun getWcInitializeUseCase(): WcInitializeUseCase + + fun getTrackingContextProxy(): TrackingContextProxy + + fun getABTestsManager(): ABTestsManager } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 0ef437226d..8db7434ef7 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -302,7 +302,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } private fun createAppThemeModeFlow(): SharedFlow { - val tangemApplication = application as TangemApplication + val tangemApplication = requireNotNull(application as? TangemApplication) { + "Application is null" + } return tangemApplication.getAppThemeModeUseCase() .filterNotNull() diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 0920d365f9..7804a24b4f 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -16,6 +16,7 @@ import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.blockchainsdk.BlockchainSDKFactory import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter +import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.ParamsInterceptor import com.tangem.core.analytics.filter.OneTimeEventFilter @@ -240,6 +241,12 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val wcInitializeUseCase get() = entryPoint.getWcInitializeUseCase() + private val trackingContextProxy + get() = entryPoint.getTrackingContextProxy() + + private val abTestsManager: ABTestsManager + get() = entryPoint.getABTestsManager() + // endregion private val appScope = MainScope() @@ -310,6 +317,8 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) } + abTestsManager.init() + appScope.launch { launch(Dispatchers.IO) { loadNativeLibraries() @@ -385,6 +394,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. userWalletsListRepository = userWalletsListRepository, tangemHotSdk = tangemHotSdk, hotWalletFeatureToggles = hotWalletFeatureToggles, + trackingContextProxy = trackingContextProxy, ), ), ) 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 deleted file mode 100644 index d1aecc29d0..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/DefaultAnalyticsContextProxy.kt +++ /dev/null @@ -1,31 +0,0 @@ -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.tap.common.extensions.addContext -import com.tangem.tap.common.extensions.eraseContext -import com.tangem.tap.common.extensions.removeContext -import com.tangem.tap.common.extensions.setContext - -/** -[REDACTED_AUTHOR] - */ -internal class DefaultAnalyticsContextProxy : AnalyticsContextProxy { - - override fun setContext(scanResponse: ScanResponse) { - Analytics.setContext(scanResponse) - } - - override fun eraseContext() { - Analytics.eraseContext() - } - - override fun addContext(scanResponse: ScanResponse) { - Analytics.addContext(scanResponse) - } - - override fun removeContext() { - Analytics.removeContext() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/DefaultChangeCardAnalyticsContextUseCase.kt b/app/src/main/java/com/tangem/tap/common/analytics/DefaultChangeCardAnalyticsContextUseCase.kt deleted file mode 100644 index 27f55538da..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/DefaultChangeCardAnalyticsContextUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.common.analytics - -import com.tangem.core.analytics.Analytics -import com.tangem.domain.analytics.ChangeCardAnalyticsContextUseCase -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.setContext - -internal class DefaultChangeCardAnalyticsContextUseCase : ChangeCardAnalyticsContextUseCase { - - override fun invoke(scanResponse: ScanResponse) { - Analytics.setContext(scanResponse) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/DefaultTrackingContextProxy.kt b/app/src/main/java/com/tangem/tap/common/analytics/DefaultTrackingContextProxy.kt new file mode 100644 index 0000000000..a32012ca51 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/DefaultTrackingContextProxy.kt @@ -0,0 +1,99 @@ +package com.tangem.tap.common.analytics + +import com.tangem.core.abtests.manager.ABTestsManager +import com.tangem.core.analytics.Analytics +import com.tangem.core.analytics.utils.TrackingContextProxy +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.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 +import com.tangem.common.extensions.calculateSha256 +import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.models.wallet.UserWalletId + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultTrackingContextProxy(private val abTestsManager: ABTestsManager) : TrackingContextProxy { + + override fun setContext(scanResponse: ScanResponse) { + val userWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build() + + Analytics.setContext(userWalletId, scanResponse) + + abTestsManager.setUserProperties( + userId = calculateUserIdHash(userWalletId), + batch = scanResponse.card.batchId, + productType = scanResponse.productType.name, + firmware = scanResponse.card.firmwareVersion.stringValue, + ) + } + + override fun setContext(userWallet: UserWallet) { + Analytics.setContext(userWallet) + + when (userWallet) { + is UserWallet.Cold -> { + setColdWalletUserProperties(userWallet) + } + is UserWallet.Hot -> { + setHotWalletUserProperties(userWallet) + } + } + } + + override fun addContext(userWallet: UserWallet) { + Analytics.addContext(userWallet) + } + + override fun setHotWalletContext() { + Analytics.setHotWalletContext() + } + + override fun eraseContext() { + Analytics.eraseContext() + abTestsManager.removeUserProperties() + } + + override fun addContext(scanResponse: ScanResponse) { + Analytics.addContext(scanResponse) + } + + override fun addHotWalletContext() { + Analytics.addHotWalletContext() + } + + override fun removeContext() { + Analytics.removeContext() + } + + private fun calculateUserIdHash(userWalletId: UserWalletId?): String? { + return userWalletId?.value + ?.calculateSha256() + ?.toHexString() + } + + private fun setColdWalletUserProperties(userWallet: UserWallet.Cold) { + abTestsManager.setUserProperties( + userId = calculateUserIdHash(userWallet.walletId), + batch = userWallet.scanResponse.card.batchId, + productType = userWallet.scanResponse.productType.name, + firmware = userWallet.scanResponse.card.firmwareVersion.stringValue, + ) + } + + private fun setHotWalletUserProperties(userWallet: UserWallet.Hot) { + abTestsManager.setUserProperties( + userId = calculateUserIdHash(userWallet.walletId), + batch = null, + productType = AnalyticsParam.ProductType.MobileWallet.value, + firmware = null, + ) + } +} \ No newline at end of file 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/CardContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt index 805090e6c0..1608ebc3f4 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt @@ -2,13 +2,13 @@ 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 import com.tangem.domain.card.analytics.IntroductionProcess import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.proxy.redux.DaggerGraphState @@ -50,17 +50,17 @@ class CardContextInterceptor( if (scanResponse.productType != ProductType.Ring && userWalletId != null) { val isWalletWithRing = runBlocking { walletsRepository.isWalletWithRing(userWalletId) } - if (isWalletWithRing) return "Ring" + if (isWalletWithRing) return AnalyticsParam.ProductType.Ring.value } return when (scanResponse.productType) { - ProductType.Note -> "Note" - ProductType.Twins -> "Twin" - ProductType.Wallet -> "Wallet" - ProductType.Wallet2 -> "Wallet 2.0" - ProductType.Ring -> "Ring" - ProductType.Start2Coin -> "Start2Coin" - ProductType.Visa -> "VISA" + ProductType.Note -> AnalyticsParam.ProductType.Note.value + ProductType.Twins -> AnalyticsParam.ProductType.Twins.value + ProductType.Wallet -> AnalyticsParam.ProductType.Wallet.value + ProductType.Wallet2 -> AnalyticsParam.ProductType.Wallet2.value + ProductType.Ring -> AnalyticsParam.ProductType.Ring.value + ProductType.Start2Coin -> AnalyticsParam.ProductType.Start2Coin.value + ProductType.Visa -> AnalyticsParam.ProductType.Visa.value else -> if (DemoHelper.isDemoCard(scanResponse)) getDemoCardProductType() else "Other" } } 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..b7be0155f7 --- /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] = AnalyticsParam.ProductType.MobileWallet.value + } + + 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..0e011c8b21 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 @@ -3,7 +3,9 @@ package com.tangem.tap.common.extensions import com.tangem.core.analytics.Analytics import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.builder.UserWalletIdBuilder +import com.tangem.tap.common.analytics.paramsInterceptor.HotWalletContextInterceptor import com.tangem.tap.common.analytics.paramsInterceptor.LinkedCardContextInterceptor /** @@ -13,8 +15,7 @@ import com.tangem.tap.common.analytics.paramsInterceptor.LinkedCardContextInterc /** * Sets the new context */ -fun Analytics.setContext(scanResponse: ScanResponse) { - val userWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build() +fun Analytics.setContext(userWalletId: UserWalletId?, scanResponse: ScanResponse) { if (userWalletId != null) { setUserId(userWalletId.stringValue) } @@ -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(userId = 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/common/images/Coil.kt b/app/src/main/java/com/tangem/tap/common/images/Coil.kt index 328a1d431e..39b02987d6 100644 --- a/app/src/main/java/com/tangem/tap/common/images/Coil.kt +++ b/app/src/main/java/com/tangem/tap/common/images/Coil.kt @@ -54,8 +54,8 @@ private class CoilTimberLogger : Logger { override fun log(tag: String, priority: Int, message: String?, throwable: Throwable?) { with(Timber.tag(COIL_LOG_TAG)) { - throwable?.let { e -> e(e, message) } - message?.let { msg -> d(msg) } + if (throwable != null) e(throwable, message) + if (message != null) d(message) } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt index a04f41db40..cde6e78a86 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt @@ -54,8 +54,8 @@ class PushNotificationDelegate(private val context: Context) { .setContentIntent(pendingIntent) .setVibrate(vibratePattern) .apply { - imageUrl?.let { uri -> - val bitmap = getBitmapImageFromUrl(uri) + if (imageUrl != null) { + val bitmap = getBitmapImageFromUrl(imageUrl) setStyle( NotificationCompat .BigPictureStyle() @@ -64,7 +64,10 @@ class PushNotificationDelegate(private val context: Context) { } } - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val service = context.getSystemService(Context.NOTIFICATION_SERVICE) + val notificationManager = requireNotNull(service as? NotificationManager) { + "NotificationManager not available" + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notificationChannel = NotificationChannel( diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index 6099bad60a..71fcd29497 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -6,6 +6,7 @@ import com.tangem.tap.features.welcome.redux.WelcomeReducer import com.tangem.tap.proxy.redux.DaggerGraphReducer import org.rekotlin.Action +@Suppress("CanBeNonNullable") fun appReducer(action: Action, state: AppState?): AppState { requireNotNull(state) if (action is AppAction.RestoreState) return action.state diff --git a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt index f944a89717..c847cb4a50 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import org.rekotlin.Middleware +@Suppress("MemberNameEqualsClassName") internal object LegacyMiddleware { private val prepareDetailsScreenJobHolder = JobHolder() @@ -29,7 +30,14 @@ internal object LegacyMiddleware { val walletsRepository = store.inject(DaggerGraphState::walletsRepository) selectedUserWallet() - .distinctUntilChanged() + .distinctUntilChanged { old, new -> + if (old is UserWallet.Cold && new is UserWallet.Cold) { + old.walletId == new.walletId && + old.scanResponse == new.scanResponse + } else { + old.walletId == new.walletId + } + } .onEach { selectedUserWallet -> val initializedAppSettingsStateContent = initializeAppSettingsState( shouldSaveUserWallets = walletsRepository.shouldSaveUserWalletsSync(), @@ -78,6 +86,7 @@ internal object LegacyMiddleware { isHidingEnabled = store.inject(DaggerGraphState::balanceHidingRepository) .getBalanceHidingSettings().isHidingEnabledInSettings, needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, + hasSecuredWallets = store.inject(DaggerGraphState::userWalletsListRepository).hasSecuredWallets(), ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/ui/SimpleAlertDialog.kt b/app/src/main/java/com/tangem/tap/common/ui/SimpleAlertDialog.kt index 3cd171d04a..078691ca7c 100644 --- a/app/src/main/java/com/tangem/tap/common/ui/SimpleAlertDialog.kt +++ b/app/src/main/java/com/tangem/tap/common/ui/SimpleAlertDialog.kt @@ -41,8 +41,8 @@ object SimpleCancelableAlertDialog { context: Context, ): AlertDialog { return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(titleRes?.let { context.getString(it) } ?: title) - setMessage(messageRes?.let { context.getString(it) } ?: message) + setTitle(if (titleRes != null) context.getString(titleRes) else title) + setMessage(if (messageRes != null) context.getString(messageRes) else message) setPositiveButton(context.getText(primaryButtonRes)) { _, _ -> primaryButtonAction() } if (secondaryButtonRes != null) { setNegativeButton(context.getText(secondaryButtonRes)) { _, _ -> secondaryButtonAction() } 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..3687dc92e7 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -2,16 +2,20 @@ 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.visa.model.VisaAuthTokens +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.model.TangemPayAuthTokens import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider 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,15 +35,22 @@ 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(TangemPayAuthTokens::class.java) } + + override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) { + withContext(dispatcherProvider.io) { + secureStorage.store(key = createCustomerAddressKey(userWalletId), value = customerWalletAddress) + } } - private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } + override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? { + return withContext(dispatcherProvider.io) { + secureStorage.getAsString(createCustomerAddressKey(userWalletId)) + } + } - override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) = + override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) = withContext(dispatcherProvider.io) { val json = tokensAdapter.toJson(tokens) @@ -47,7 +60,7 @@ internal class DefaultTangemPayStorage @Inject constructor( ) } - override suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? = + override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? = withContext(dispatcherProvider.io) { secureStorage.get(createKey(customerWalletAddress)) ?.decodeToString(throwOnInvalidSequence = true) @@ -67,15 +80,42 @@ 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 storeCheckCustomerWalletResult(userWalletId: UserWalletId) { + appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), true) } + override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? { + return appPreferencesStore.getSyncOrNull(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId)) + } + + 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" private fun createOrderIdKey(address: String): String = "${ORDER_ID_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/HapticModule.kt b/app/src/main/java/com/tangem/tap/di/HapticModule.kt index dc04a3ce72..3db66d0e6e 100644 --- a/app/src/main/java/com/tangem/tap/di/HapticModule.kt +++ b/app/src/main/java/com/tangem/tap/di/HapticModule.kt @@ -4,9 +4,9 @@ import android.content.Context import android.os.Build import android.os.Vibrator import android.os.VibratorManager -import com.tangem.tap.common.haptic.DefaultVibratorHapticManager import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager +import com.tangem.tap.common.haptic.DefaultVibratorHapticManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -22,10 +22,14 @@ class HapticModule { @Singleton fun provideHapticManager(@ApplicationContext context: Context): VibratorHapticManager { val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + val service = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) + val vibratorManager = requireNotNull(service as? VibratorManager) { + "VibratorManager not available" + } vibratorManager.defaultVibrator } else { - context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + val service = context.getSystemService(Context.VIBRATOR_SERVICE) + requireNotNull(service as? Vibrator) { "Vibrator service not available" } } return if (vibrator.hasVibrator()) { 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/analytics/AnalyticsModule.kt b/app/src/main/java/com/tangem/tap/di/analytics/AnalyticsModule.kt index 60fea2b718..c290c819a2 100644 --- a/app/src/main/java/com/tangem/tap/di/analytics/AnalyticsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/analytics/AnalyticsModule.kt @@ -1,10 +1,9 @@ package com.tangem.tap.di.analytics +import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.AppInstanceIdProvider -import com.tangem.core.analytics.utils.AnalyticsContextProxy -import com.tangem.domain.analytics.ChangeCardAnalyticsContextUseCase -import com.tangem.tap.common.analytics.DefaultAnalyticsContextProxy -import com.tangem.tap.common.analytics.DefaultChangeCardAnalyticsContextUseCase +import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.tap.common.analytics.DefaultTrackingContextProxy import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAppInstanceIdProvider import dagger.Module import dagger.Provides @@ -18,13 +17,8 @@ internal object AnalyticsModule { @Provides @Singleton - fun provideChangeCardAnalyticsContextUseCase(): ChangeCardAnalyticsContextUseCase { - return DefaultChangeCardAnalyticsContextUseCase() - } - - @Provides - @Singleton - fun provideAnalyticsContextProxy(): AnalyticsContextProxy = DefaultAnalyticsContextProxy() + fun provideAnalyticsContextProxy(abtestsManager: ABTestsManager): TrackingContextProxy = + DefaultTrackingContextProxy(abtestsManager) @Provides @Singleton 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..2afd20f31a 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,8 +3,14 @@ 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.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.ArchiveCryptoPortfolioUseCase +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 com.tangem.feature.referral.data.ExternalReferralRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -32,17 +38,27 @@ internal object AccountDomainModule { @Provides @Singleton fun provideUpdateCryptoPortfolioUseCase( + singleAccountListFetcher: SingleAccountListFetcher, accountsCRUDRepository: AccountsCRUDRepository, ): UpdateCryptoPortfolioUseCase { - return UpdateCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository) + return UpdateCryptoPortfolioUseCase( + singleAccountListFetcher = singleAccountListFetcher, + crudRepository = accountsCRUDRepository, + ) } @Provides @Singleton fun provideArchiveCryptoPortfolioUseCase( + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, accountsCRUDRepository: AccountsCRUDRepository, + referralRepository: ExternalReferralRepository, ): ArchiveCryptoPortfolioUseCase { - return ArchiveCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository) + return ArchiveCryptoPortfolioUseCase( + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + crudRepository = accountsCRUDRepository, + referralRepository = referralRepository, + ) } @Provides @@ -50,10 +66,12 @@ internal object AccountDomainModule { fun provideRecoverCryptoPortfolioUseCase( accountsCRUDRepository: AccountsCRUDRepository, mainAccountTokensMigration: MainAccountTokensMigration, + cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, ): RecoverCryptoPortfolioUseCase { return RecoverCryptoPortfolioUseCase( crudRepository = accountsCRUDRepository, mainAccountTokensMigration = mainAccountTokensMigration, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, ) } @@ -82,4 +100,16 @@ internal object AccountDomainModule { accountsFeatureToggles = accountsFeatureToggles, ) } + + @Provides + @Singleton + fun provideApplyAccountListSortingUseCase( + accountsCRUDRepository: AccountsCRUDRepository, + dispatchers: CoroutineDispatcherProvider, + ): ApplyAccountListSortingUseCase { + return ApplyAccountListSortingUseCase( + accountsCRUDRepository = accountsCRUDRepository, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index f814746b0a..8c0d2628db 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -33,7 +33,7 @@ internal object CardDomainModule { @Provides @Singleton - fun provideIsDemoCardUseCase(): IsDemoCardUseCase = IsDemoCardUseCase(config = DemoConfig()) + fun provideIsDemoCardUseCase(): IsDemoCardUseCase = IsDemoCardUseCase(config = DemoConfig) @Provides @Singleton diff --git a/app/src/main/java/com/tangem/tap/di/domain/HotWalletDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/HotWalletDomainModule.kt new file mode 100644 index 0000000000..57fd8da9ae --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/HotWalletDomainModule.kt @@ -0,0 +1,27 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase +import com.tangem.domain.hotwallet.SetAccessCodeSkippedUseCase +import com.tangem.domain.hotwallet.repository.HotWalletRepository +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 HotWalletDomainModule { + + @Provides + @Singleton + fun provideGetAccessCodeSkippedUseCase(hotWalletRepository: HotWalletRepository): GetAccessCodeSkippedUseCase { + return GetAccessCodeSkippedUseCase(hotWalletRepository) + } + + @Provides + @Singleton + fun provideSetAccessCodeSkippedUseCase(hotWalletRepository: HotWalletRepository): SetAccessCodeSkippedUseCase { + return SetAccessCodeSkippedUseCase(hotWalletRepository) + } +} \ No newline at end of file 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..2ed2a31346 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,8 @@ 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.account.supplier.SingleAccountListSupplier import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.nft.* import com.tangem.domain.nft.repository.NFTRepository @@ -24,9 +27,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 +67,12 @@ internal object NFTDomainModule { @Singleton fun providesGetNFTAvailableNetworksUseCase( nftRepository: NFTRepository, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currenciesRepository: CurrenciesRepository, ): GetNFTNetworksUseCase = GetNFTNetworksUseCase( currenciesRepository = currenciesRepository, nftRepository = nftRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, ) @Provides @@ -138,8 +147,15 @@ internal object NFTDomainModule { fun provideClearNFTCacheUseCase( nftRepository: NFTRepository, currenciesRepository: CurrenciesRepository, + accountsFeatureToggles: AccountsFeatureToggles, + singleAccountListSupplier: SingleAccountListSupplier, ): ObserveAndClearNFTCacheIfNeedUseCase { - return ObserveAndClearNFTCacheIfNeedUseCase(nftRepository, currenciesRepository) + return ObserveAndClearNFTCacheIfNeedUseCase( + nftRepository = nftRepository, + currenciesRepository = currenciesRepository, + accountsFeatureToggles = accountsFeatureToggles, + singleAccountListSupplier = singleAccountListSupplier, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt new file mode 100644 index 0000000000..dda3134a35 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt @@ -0,0 +1,41 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.news.repository.NewsRepository +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object NewsDomainModule { + + @Provides + @Singleton + fun provideGetNewsCategoriesUseCase(repository: NewsRepository): GetNewsCategoriesUseCase { + return GetNewsCategoriesUseCase(repository) + } + + @Provides + @Singleton + fun provideObserveNewsDetailsUseCase(repository: NewsRepository): ObserveNewsDetailsUseCase { + return ObserveNewsDetailsUseCase(repository) + } + + @Provides + @Singleton + fun provideObserveTrendingNewsUseCase(repository: NewsRepository): ManageTrendingNewsUseCase { + return ManageTrendingNewsUseCase(repository) + } + + @Provides + @Singleton + fun provideGetNewsListBatchFlowUseCase(repository: NewsRepository): GetNewsListBatchFlowUseCase { + return GetNewsListBatchFlowUseCase(repository) + } +} \ No newline at end of file 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/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index 6fae7d3ea2..631f9a46a0 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -1,10 +1,12 @@ package com.tangem.tap.di.domain import com.tangem.domain.staking.* -import com.tangem.domain.staking.repositories.StakingActionRepository +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.repositories.StakeKitActionRepository import com.tangem.domain.staking.repositories.StakingErrorResolver +import com.tangem.domain.staking.repositories.StakeKitRepository import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.staking.repositories.StakingTransactionHashRepository +import com.tangem.domain.staking.repositories.StakeKitTransactionHashRepository import com.tangem.domain.staking.single.SingleYieldBalanceFetcher import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.usecase.StakingApyFlowUseCase @@ -17,6 +19,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) +@Suppress("TooManyFunctions") internal object StakingDomainModule { @Provides @@ -34,11 +37,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideGetStakingEntryInfoUseCase( - stakingRepository: StakingRepository, + stakeKitRepository: StakeKitRepository, stakingErrorResolver: StakingErrorResolver, ): GetStakingEntryInfoUseCase { return GetStakingEntryInfoUseCase( - stakingRepository = stakingRepository, + stakeKitRepository = stakeKitRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -46,11 +49,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideGetYieldUseCase( - stakingRepository: StakingRepository, + stakeKitRepository: StakeKitRepository, stakingErrorResolver: StakingErrorResolver, ): GetYieldUseCase { return GetYieldUseCase( - stakingRepository = stakingRepository, + stakeKitRepository = stakeKitRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -58,13 +61,13 @@ internal object StakingDomainModule { @Provides @Singleton fun provideFetchActionsUseCase( - stakingRepository: StakingRepository, - stakingActionRepository: StakingActionRepository, + stakeKitRepository: StakeKitRepository, + stakeKitActionRepository: StakeKitActionRepository, stakingErrorResolver: StakingErrorResolver, ): FetchActionsUseCase { return FetchActionsUseCase( - stakingRepository = stakingRepository, - stakingActionRepository = stakingActionRepository, + stakeKitRepository = stakeKitRepository, + stakeKitActionRepository = stakeKitActionRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -72,11 +75,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideGetActionsUseCase( - stakingActionRepository: StakingActionRepository, + stakeKitActionRepository: StakeKitActionRepository, stakingErrorResolver: StakingErrorResolver, ): GetActionsUseCase { return GetActionsUseCase( - stakingActionRepository = stakingActionRepository, + stakeKitActionRepository = stakeKitActionRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -84,11 +87,25 @@ internal object StakingDomainModule { @Provides @Singleton fun provideGetStakingTokensUseCase( - stakingRepository: StakingRepository, + stakeKitRepository: StakeKitRepository, stakingErrorResolver: StakingErrorResolver, ): FetchStakingTokensUseCase { return FetchStakingTokensUseCase( - stakingRepository = stakingRepository, + stakeKitRepository = stakeKitRepository, + stakingErrorResolver = stakingErrorResolver, + ) + } + + @Provides + @Singleton + fun provideFetchStakingOptionsUseCase( + stakeKitRepository: StakeKitRepository, + p2pRepository: P2PEthPoolRepository, + stakingErrorResolver: StakingErrorResolver, + ): FetchStakingOptionsUseCase { + return FetchStakingOptionsUseCase( + stakeKitRepository = stakeKitRepository, + p2pRepository = p2pRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -108,11 +125,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideGetStakingTransactionsUseCase( - stakingRepository: StakingRepository, + stakeKitRepository: StakeKitRepository, stakingErrorResolver: StakingErrorResolver, ): GetStakingTransactionsUseCase { return GetStakingTransactionsUseCase( - stakingRepository = stakingRepository, + stakeKitRepository = stakeKitRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -120,11 +137,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideGasEstimateUseCase( - stakingRepository: StakingRepository, + stakeKitRepository: StakeKitRepository, stakingErrorResolver: StakingErrorResolver, ): EstimateGasUseCase { return EstimateGasUseCase( - stakingRepository = stakingRepository, + stakeKitRepository = stakeKitRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -132,11 +149,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideSubmitHashUseCase( - stakingTransactionHashRepository: StakingTransactionHashRepository, + stakeKitTransactionHashRepository: StakeKitTransactionHashRepository, stakingErrorResolver: StakingErrorResolver, ): SubmitHashUseCase { return SubmitHashUseCase( - stakingTransactionHashRepository = stakingTransactionHashRepository, + stakeKitTransactionHashRepository = stakeKitTransactionHashRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -144,11 +161,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideSaveUnsubmittedHashUseCase( - stakingTransactionHashRepository: StakingTransactionHashRepository, + stakeKitTransactionHashRepository: StakeKitTransactionHashRepository, stakingErrorResolver: StakingErrorResolver, ): SaveUnsubmittedHashUseCase { return SaveUnsubmittedHashUseCase( - stakingTransactionHashRepository = stakingTransactionHashRepository, + stakeKitTransactionHashRepository = stakeKitTransactionHashRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -166,11 +183,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideSendUnsubmittedHashesUseCase( - stakingTransactionHashRepository: StakingTransactionHashRepository, + stakeKitTransactionHashRepository: StakeKitTransactionHashRepository, stakingErrorResolver: StakingErrorResolver, ): SendUnsubmittedHashesUseCase { return SendUnsubmittedHashesUseCase( - stakingTransactionHashRepository = stakingTransactionHashRepository, + stakeKitTransactionHashRepository = stakeKitTransactionHashRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -178,11 +195,11 @@ internal object StakingDomainModule { @Provides @Singleton fun provideGetConstructedStakingTransactionUseCase( - stakingRepository: StakingRepository, + stakeKitRepository: StakeKitRepository, stakingErrorResolver: StakingErrorResolver, ): GetConstructedStakingTransactionUseCase { return GetConstructedStakingTransactionUseCase( - stakingRepository = stakingRepository, + stakeKitRepository = stakeKitRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -222,9 +239,9 @@ internal object StakingDomainModule { @Provides @Singleton fun provideStakingApyFlowUseCase( - stakingRepository: StakingRepository, + stakeKitRepository: StakeKitRepository, stakingFeatureToggles: StakingFeatureToggles, ): StakingApyFlowUseCase { - return StakingApyFlowUseCase(stakingRepository, stakingFeatureToggles) + return StakingApyFlowUseCase(stakeKitRepository, stakingFeatureToggles) } } \ No newline at end of file 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/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 1a5ebb1043..8965f60090 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -31,7 +31,7 @@ internal object TransactionDomainModule { fun provideGetFeeUseCase(walletManagersFacade: WalletManagersFacade): GetFeeUseCase { return GetFeeUseCase( walletManagersFacade = walletManagersFacade, - demoConfig = DemoConfig(), + demoConfig = DemoConfig, ) } @@ -52,7 +52,7 @@ internal object TransactionDomainModule { dispatchers: CoroutineDispatcherProvider, ): SendTransactionUseCase { return SendTransactionUseCase( - demoConfig = DemoConfig(), + demoConfig = DemoConfig, cardSdkConfigRepository = cardSdkConfigRepository, transactionRepository = transactionRepository, walletManagersFacade = walletManagersFacade, @@ -131,7 +131,7 @@ internal object TransactionDomainModule { fun provideEstimateFeeUseCase(walletManagersFacade: WalletManagersFacade): EstimateFeeUseCase { return EstimateFeeUseCase( walletManagersFacade = walletManagersFacade, - demoConfig = DemoConfig(), + demoConfig = DemoConfig, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index f8af3f022b..f6d5141d1e 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -166,10 +166,34 @@ internal object WalletsDomainModule { @Provides @Singleton - fun providesUnlockWalletUseCase(userWalletsListManager: UserWalletsListManager): UnlockWalletsUseCase { + fun providesUnlockWalletsUseCase(userWalletsListManager: UserWalletsListManager): UnlockWalletsUseCase { return UnlockWalletsUseCase(userWalletsListManager = userWalletsListManager) } + @Provides + @Singleton + fun providesUnlockWalletUseCase( + nonBiometricUnlockWalletUseCase: NonBiometricUnlockWalletUseCase, + userWalletsListRepository: UserWalletsListRepository, + ): UnlockWalletUseCase { + return UnlockWalletUseCase( + nonBiometricUnlockWalletUseCase = nonBiometricUnlockWalletUseCase, + userWalletsListRepository = userWalletsListRepository, + ) + } + + @Provides + @Singleton + fun providesNonBiometricUnlockWalletUseCase( + userWalletsListRepository: UserWalletsListRepository, + walletsRepository: WalletsRepository, + ): NonBiometricUnlockWalletUseCase { + return NonBiometricUnlockWalletUseCase( + userWalletsListRepository = userWalletsListRepository, + walletsRepository = walletsRepository, + ) + } + @Provides @Singleton fun providesSelectWalletUseCase( @@ -483,4 +507,18 @@ internal object WalletsDomainModule { dispatchers = dispatcherProvider, ) } + + @Provides + @Singleton + fun provideHasSecuredWalletsUseCase( + userWalletsListRepository: UserWalletsListRepository, + ): HasSecuredWalletsUseCase { + return HasSecuredWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + } + + @Provides + @Singleton + fun provideSyncWalletWithRemoteUseCase(walletsRepository: WalletsRepository): SyncWalletWithRemoteUseCase { + return SyncWalletWithRemoteUseCase(walletsRepository = walletsRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt b/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt index 9deccc04e6..72762cb7a0 100644 --- a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt @@ -2,11 +2,11 @@ package com.tangem.tap.domain import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.Wallet -import com.tangem.core.analytics.Analytics import com.tangem.domain.common.extensions.withMainContext import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.extensions.setContext +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.global.GlobalAction +import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.store import com.tangem.tap.tangemSdkManager import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider @@ -30,11 +30,12 @@ class TapWalletManager( // ensuring that only one job is active at any given time. loadUserWalletDataJob = CoroutineScope(dispatchers.io) .launch { loadUserWalletData(userWallet) } - .also { it.join() } + .apply { join() } } private suspend fun loadUserWalletData(userWallet: UserWallet) { - Analytics.setContext(userWallet) + val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy) + trackingContextProxy.setContext(userWallet) if (userWallet is UserWallet.Cold) { val scanResponse = userWallet.scanResponse diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index 17ce65a238..ac18c8b943 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -12,6 +12,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R @@ -47,6 +48,7 @@ import javax.inject.Singleton internal class LegacyScanProcessor @Inject constructor( @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, + private val trackingContextProxy: TrackingContextProxy, ) { suspend fun scan( @@ -118,16 +120,14 @@ internal class LegacyScanProcessor @Inject constructor( } } - private fun sendAnalytics(analyticsEvent: AnalyticsEvent?, scanResponse: ScanResponse) { - analyticsEvent?.let { event -> - // this workaround needed to send CardWasScannedEvent without adding a context - val interceptor = CardContextInterceptor(scanResponse) - val params = event.params.toMutableMap() - interceptor.intercept(params) - event.params = params.toMap() + private fun sendAnalytics(analyticsEvent: AnalyticsEvent, scanResponse: ScanResponse) { + // this workaround needed to send CardWasScannedEvent without adding a context + val interceptor = CardContextInterceptor(scanResponse) + val params = analyticsEvent.params.toMutableMap() + interceptor.intercept(params) + analyticsEvent.params = params.toMap() - Analytics.send(event) - } + Analytics.send(analyticsEvent) } // TODO: [REDACTED_JIRA] @@ -211,7 +211,7 @@ internal class LegacyScanProcessor @Inject constructor( }, ) { if (OnboardingHelper.isOnboardingCase(scanResponse)) { - Analytics.addContext(scanResponse) + trackingContextProxy.addContext(scanResponse) onWalletNotCreated() navigateTo( AppRoute.Onboarding( @@ -220,7 +220,7 @@ internal class LegacyScanProcessor @Inject constructor( ), ) { onProgressStateChange(it) } } else { - Analytics.setContext(scanResponse) + trackingContextProxy.setContext(scanResponse) val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase).invokeSync() diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt index f632d90194..a3693ba6d2 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt @@ -3,15 +3,12 @@ package com.tangem.tap.domain.scanCard.chains import arrow.core.left import arrow.core.right import com.tangem.common.routing.AppRoute -import com.tangem.core.analytics.Analytics import com.tangem.domain.card.ScanCardException import com.tangem.domain.card.common.util.twinsIsTwinned import com.tangem.domain.core.chain.Chain import com.tangem.domain.core.chain.ResultChain import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.addContext import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.extensions.setContext import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.proxy.redux.DaggerGraphState @@ -34,9 +31,11 @@ class CheckForOnboardingChain( ) : ResultChain() { override suspend fun launch(previousChainResult: ScanResponse): ScanChainResult { + val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy) + return when { OnboardingHelper.isOnboardingCase(previousChainResult) -> { - Analytics.addContext(previousChainResult) + trackingContextProxy.addContext(previousChainResult) ScanChainException.OnboardingNeeded( AppRoute.Onboarding( scanResponse = previousChainResult, @@ -45,7 +44,7 @@ class CheckForOnboardingChain( ).left() } else -> { - Analytics.setContext(previousChainResult) + trackingContextProxy.setContext(previousChainResult) val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase) .invokeSync() 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..89c571cd1e 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,13 @@ 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.wallets.derivations.derivationStyleProvider import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.operations.ScanTask import com.tangem.operations.derivation.DerivationTaskResponse @@ -44,6 +44,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 +63,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 { @@ -76,13 +78,12 @@ internal class DefaultTangemSdkManager( secureStorage = tangemSdk.secureStorage, ) } + override val needEnrollBiometrics: Boolean + get() = tangemSdk.authenticationManager.needEnrollBiometrics override val canUseBiometry: Boolean get() = tangemSdk.authenticationManager.canAuthenticate || needEnrollBiometrics - override val needEnrollBiometrics: Boolean - get() = tangemSdk.authenticationManager.needEnrollBiometrics - override val keystoreManager: KeystoreManager get() = tangemSdk.keystoreManager @@ -511,6 +512,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/CreateProductWalletTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/CreateProductWalletTask.kt index ac172d1b9c..5a3a30ef87 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/CreateProductWalletTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/CreateProductWalletTask.kt @@ -14,10 +14,11 @@ import com.tangem.common.map import com.tangem.crypto.bip39.Mnemonic import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.card.CardTypesResolver -import com.tangem.domain.wallets.derivations.DerivationStyleProvider import com.tangem.domain.card.common.TapWorkarounds.isTestCard import com.tangem.domain.card.configs.CardConfig +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.wallets.derivations.DerivationStyleProvider import com.tangem.operations.backup.PrimaryCard import com.tangem.operations.backup.StartPrimaryCardLinkingCommand import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask @@ -60,7 +61,13 @@ class CreateProductWalletTask( val cardDto = CardDTO(card) val commandProcessor = when { - cardTypesResolver.isTangemNote() -> CreateWalletTangemNote(cardTypesResolver) + /** + * @workaround isDemoNoteAsMultiwallet + * There were produced 20k Note demo cards that should work like multiwallet (except Onboarding) + * for that reasons we've just added some specific checks for their BatchId + */ + DemoConfig.isDemoNoteAsMultiwallet(card.cardId) || cardTypesResolver.isTangemNote() -> + CreateWalletTangemNote(cardTypesResolver) cardTypesResolver.isTangemTwins() -> throw UnsupportedOperationException("Use the TwinCardsManager to create a wallet") @@ -374,7 +381,7 @@ private class CreateWalletTangemWallet( private fun getBlockchains(cardId: String, card: CardDTO): List { return when { - DemoHelper.isDemoCardId(cardId) -> DemoHelper.config.demoBlockchains.toList() + DemoHelper.isDemoCardId(cardId) -> DemoHelper.config.getDemoBlockchains(cardId).toList() card.isTestCard -> listOf(Blockchain.BitcoinTestnet, Blockchain.EthereumTestnet) else -> listOf(Blockchain.Bitcoin, Blockchain.Ethereum) } diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt index c1475265db..eb99c14c88 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/DerivationsFinder.kt @@ -38,7 +38,7 @@ internal class DerivationsFinder( getBlockchains(userWalletId) }.ifEmpty { if (DemoHelper.isDemoCardId(card.cardId)) { - getDemoBlockchains(derivationStyle) + getDemoBlockchains(derivationStyle, card.cardId) } else { getDefaultBlockchains(derivationStyle) } @@ -77,8 +77,8 @@ internal class DerivationsFinder( } // TODO: Move to user wallet config - private fun getDemoBlockchains(derivationStyle: DerivationStyle?): MutableSet { - return DemoHelper.config.demoBlockchains.mapToBlockchainsWithDerivations(derivationStyle) + private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): MutableSet { + return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle) } // TODO: Move to user wallet config 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..5c813c1a93 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateAddressAndSignChallengeTask.kt @@ -0,0 +1,134 @@ +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.domain.wallets.builder.UserWalletIdBuilder +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 address = when (val derivationResult = runDerivationTask(session, wallet)) { + is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error) + is CompletionResult.Success -> generateAddressFromExtendedKey(derivationResult.data) + } + + val userWalletId = UserWalletIdBuilder.walletPublicKey(wallet.publicKey) + val challenge = withContext(dispatchersProvider.io) { + visaAuthRemoteDataSource.getCustomerWalletAuthChallenge( + customerWalletAddress = address, + customerWalletId = userWalletId.stringValue, + ) + }.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/VisaCardActivationTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt index 547876b22f..27f82f9b3b 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt @@ -177,7 +177,7 @@ class VisaCardActivationTask @AssistedInject constructor( .getOrElse { raise(it.tangemError) } if (remoteState !is VisaActivationRemoteState.CardWalletSignatureRequired) { - return raise(VisaActivationError.WrongRemoteState.tangemError) + raise(VisaActivationError.WrongRemoteState.tangemError) } visaActivationRepository.getCardWalletAcceptanceData( 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/domain/userWalletList/di/UserWalletsListManagerModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt index 5ea3c3c88b..e0b08af719 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt @@ -15,6 +15,7 @@ import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.hot.sdk.TangemHotSdk import com.tangem.sdk.storage.AndroidSecureStorage import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.sdk.storage.createEncryptedSharedPreferences @@ -122,6 +123,7 @@ internal object UserWalletsListManagerModule { passwordRequester: HotWalletPasswordRequester, appPreferencesStore: AppPreferencesStore, hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, + tangemHotSdk: TangemHotSdk, ): UserWalletsListRepository { val moshi = buildMoshi() val secureStorage = buildSecureStorage(applicationContext = applicationContext) @@ -168,6 +170,7 @@ internal object UserWalletsListManagerModule { appPreferencesStore = appPreferencesStore, savePersistentInformation = ProviderSuspend { true }, // Always save persistent information for now hotWalletAccessCodeAttemptsRepository = hotWalletAccessCodeAttemptsRepository, + tangemHotSdk = tangemHotSdk, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt index ae13a2ec94..85b958f1d1 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt @@ -36,11 +36,6 @@ internal class BiometricUserWalletsListManager( .mapLatest { it.userWallets } .distinctUntilChanged() - override val savedWalletsCount: Flow - get() = state - .mapLatest { walletsCount } - .distinctUntilChanged() - override val userWalletsSync: List get() = state.value.userWallets @@ -71,6 +66,11 @@ internal class BiometricUserWalletsListManager( override val walletsCount: Int get() = state.value.userWallets.size + override val savedWalletsCount: Flow + get() = state + .mapLatest { walletsCount } + .distinctUntilChanged() + override suspend fun unlock(type: UnlockType): CompletionResult { return unlockAndSetSelectedUserWallet(type) .mapFailure { error -> diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/RuntimeUserWalletsListManager.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/RuntimeUserWalletsListManager.kt index 31ad6301f0..8ee8847ffa 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/RuntimeUserWalletsListManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/RuntimeUserWalletsListManager.kt @@ -20,11 +20,6 @@ internal class RuntimeUserWalletsListManager : UserWalletsListManager { .mapLatest { listOfNotNull(it.userWallet) } .distinctUntilChanged() - override val savedWalletsCount: Flow - get() = state - .mapLatest { walletsCount } - .distinctUntilChanged() - override val selectedUserWallet: Flow get() = state .mapLatest { it.userWallet } @@ -46,6 +41,11 @@ internal class RuntimeUserWalletsListManager : UserWalletsListManager { override val walletsCount: Int get() = if (hasUserWallets) 1 else 0 + override val savedWalletsCount: Flow + get() = state + .mapLatest { walletsCount } + .distinctUntilChanged() + override suspend fun select(userWalletId: UserWalletId): CompletionResult = catching { state.value.userWallet ?.takeIf { it.walletId == userWalletId } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 36b368b111..af0ce672e0 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -2,9 +2,11 @@ package com.tangem.tap.domain.userWalletList.repository import arrow.core.Either import arrow.core.left +import arrow.core.raise.Raise import arrow.core.raise.either import arrow.core.right import com.tangem.common.* +import com.tangem.common.core.TangemSdkError import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getSyncOrDefault @@ -18,6 +20,7 @@ import com.tangem.domain.wallets.R import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletPasswordRequester +import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotWalletId import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey @@ -27,6 +30,8 @@ import com.tangem.tap.domain.userWalletList.utils.toUserWallets import com.tangem.tap.domain.userWalletList.utils.updateWith import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend +import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.extensions.addOrReplace import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -44,6 +49,7 @@ internal class DefaultUserWalletsListRepository( private val savePersistentInformation: ProviderSuspend, private val appPreferencesStore: AppPreferencesStore, private val hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, + private val tangemHotSdk: TangemHotSdk, ) : UserWalletsListRepository { override val userWallets = MutableStateFlow?>(null) @@ -119,14 +125,19 @@ internal class DefaultUserWalletsListRepository( } } + val oldUserWallet = userWalletsSync().find { it.walletId == userWallet.walletId } + + if (oldUserWallet != null) { + checkForUpgradeAndDeleteHotWalletIfNeeded( + newUserWallet = userWallet, + oldUserWallet = oldUserWallet, + ) + } + // update the userWallets state and add if it doesn't exist updateWallets { currentWallets -> val wallets = currentWallets.orEmpty() - if (wallets.any { it.walletId == userWallet.walletId }) { - wallets.map { if (it.walletId == userWallet.walletId) userWallet else it } - } else { - wallets + userWallet - } + wallets.addOrReplace(userWallet) { it.walletId == userWallet.walletId } } // update the selectedUserWallet state if it is the only wallet @@ -149,7 +160,7 @@ internal class DefaultUserWalletsListRepository( val encryptionKey = userWallet.encryptionKey ?: raise(SetLockError.UserWalletLocked) - runCatching { + runSuspendCatching { userWalletEncryptionKeysRepository.save( encryptionKey = UserWalletEncryptionKey( walletId = userWalletId, @@ -193,6 +204,8 @@ internal class DefaultUserWalletsListRepository( userWalletEncryptionKeysRepository.delete(userWalletIds) + removeHotWalletsFromSDK(userWalletIds) + userWallets.update { currentWallets -> val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } selectedUserWallet.update { currentSelected -> @@ -226,16 +239,16 @@ internal class DefaultUserWalletsListRepository( } UserWalletsListRepository.UnlockMethod.AccessCode -> { if (userWallet !is UserWallet.Hot) { - raise(UnlockWalletError.UnableToUnlock) + raise(UnlockWalletError.UnableToUnlock.Empty) } val encryptionKey = requestPasswordRecursive( hotWalletId = userWallet.hotWalletId, block = { password -> - runCatching { + runSuspendCatching { userWalletEncryptionKeysRepository.getEncryptedWithPassword(userWalletId, password) - }.onFailure { - raise(UnlockWalletError.UnableToUnlock) + }.onFailure { error -> + raise(UnlockWalletError.UnableToUnlock.RawException(error)) }.getOrNull() }, biometryFallback = { @@ -251,13 +264,11 @@ internal class DefaultUserWalletsListRepository( sensitiveInformationRepository.getAll(listOf(encryptionKey)) .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } - .doOnFailure { error -> - raise(UnlockWalletError.UnableToUnlock) - } + .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) } } is UserWalletsListRepository.UnlockMethod.Scan -> { if (userWallet !is UserWallet.Cold) { - raise(UnlockWalletError.UnableToUnlock) + raise(UnlockWalletError.UnableToUnlock.Empty) } val scanResponse = unlockMethod.scanResponse ?: run { @@ -276,24 +287,27 @@ internal class DefaultUserWalletsListRepository( val encryptionKey = UserWalletEncryptionKey( walletId = userWallet.walletId, - encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock), + encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty), ) sensitiveInformationRepository.getAll(listOf(encryptionKey)) .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } } - .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock) } + .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) } } } } override suspend fun unlockAllWallets(): Either = either { - val userWallets = userWalletsSync() - val userWalletIds = userWallets.map { it.walletId }.toSet() - val biometricKeys = runCatching { + val userWallets = userWalletsSync().filter { it.isLocked } + + if (userWallets.isEmpty()) { + return@either + } + + val biometricKeys = runSuspendCatching { userWalletEncryptionKeysRepository.getAllBiometric() - }.getOrElse { - // TODO handle error properly [REDACTED_TASK_KEY] - raise(UnlockWalletError.UserCancelled) + }.getOrElse { exception -> + catchBiometricException(exception) } val unsecuredKeys = userWalletEncryptionKeysRepository.getAllUnsecured() @@ -309,16 +323,17 @@ internal class DefaultUserWalletsListRepository( removePasswordAttempts(it) } - // if we cant unlock all wallets - if (userWalletIds.all { it in unlockedWalletsIds }.not()) { - raise(UnlockWalletError.UnableToUnlock) + // if we cant unlock any of the locked wallets, return error + // (isLocked remains `true` here because we haven't updated the wallets yet) + if (unlockedWallets.any { it.isLocked }.not()) { + raise(UnlockWalletError.UnableToUnlock.Empty) } sensitiveInformationRepository.getAll(allKeys) .doOnSuccess { sensitiveInfo -> - updateWallets { userWallets.updateWith(sensitiveInfo) } + updateWallets { wallets -> wallets?.updateWith(sensitiveInfo) } } - .doOnFailure { raise(UnlockWalletError.UnableToUnlock) } + .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) } } override suspend fun lockAllWallets(): Either = either { @@ -345,6 +360,40 @@ internal class DefaultUserWalletsListRepository( userWalletEncryptionKeysRepository.clear() } + override suspend fun hasSecuredWallets(): Boolean { + val userWallets = userWalletsSync() + val unsecuredWalletIds = userWalletEncryptionKeysRepository.getAllUnsecured().map { it.walletId }.toSet() + return userWallets.any { it.walletId !in unsecuredWalletIds } + } + + private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded( + newUserWallet: UserWallet, + oldUserWallet: UserWallet, + ) { + if (newUserWallet.walletId == oldUserWallet.walletId && + oldUserWallet is UserWallet.Hot && newUserWallet is UserWallet.Cold + ) { + removeHotWalletsFromSDK(walletIds = listOf(oldUserWallet.walletId)) + // When upgrading from Hot to Cold, if biometric lock is available, set it + if (hasBiometry()) { + setLock(newUserWallet.walletId, LockMethod.Biometric, changeUnsecured = true) + } + // Remove any encryption keys related to the old hot wallet + userWalletEncryptionKeysRepository.removeEncryptedWithPasswordKey(oldUserWallet.walletId) + userWalletEncryptionKeysRepository.removeUnsecuredKey(oldUserWallet.walletId) + } + } + + private suspend fun removeHotWalletsFromSDK(walletIds: List) { + val hotWalletsToDelete = userWalletsSync() + .filterIsInstance() + .filter { walletIds.contains(it.walletId) } + + hotWalletsToDelete.forEach { + tangemHotSdk.delete(it.hotWalletId) + } + } + private suspend fun requestPasswordRecursive( hotWalletId: HotWalletId, block: suspend (CharArray) -> UserWalletEncryptionKey?, @@ -410,6 +459,27 @@ internal class DefaultUserWalletsListRepository( } } + private fun Raise.catchBiometricException(ex: Throwable): Nothing { + val reason = when (ex) { + is TangemSdkError.AuthenticationLockout -> + UnlockWalletError.UnableToUnlock.Reason.BiometricsAuthenticationLockout(isPermanent = false) + is TangemSdkError.AuthenticationPermanentLockout -> + UnlockWalletError.UnableToUnlock.Reason.BiometricsAuthenticationLockout(isPermanent = true) + is TangemSdkError.KeystoreInvalidated -> + UnlockWalletError.UnableToUnlock.Reason.AllKeysInvalidated + is TangemSdkError.AuthenticationUnavailable -> + UnlockWalletError.UnableToUnlock.Reason.BiometricsAuthenticationDisabled + + is TangemSdkError.AuthenticationCanceled, + is TangemSdkError.AuthenticationAlreadyInProgress, + -> raise(UnlockWalletError.UserCancelled) + + else -> raise(UnlockWalletError.UnableToUnlock.RawException(ex)) + } + + raise(UnlockWalletError.UnableToUnlock.WithReason(reason)) + } + /** * Find the nearest available wallet that can be selected * diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt index faf5553d90..e64db24760 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt @@ -66,6 +66,14 @@ internal class UserWalletEncryptionKeysRepository( authenticatedStorage.delete(StorageKey.UserWalletEncryptionKey(userWalletId).name) } + fun removeEncryptedWithPasswordKey(userWalletId: UserWalletId) { + secureStorage.delete(StorageKey.UserWalletEncryptionKeyEncrypted(userWalletId).name) + } + + fun removeUnsecuredKey(userWalletId: UserWalletId) { + secureStorage.delete(StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name) + } + suspend fun getAllUnsecured(): List = withContext(dispatchers.io) { getUserWalletsIds().mapNotNull { userWalletId -> secureStorage.get(account = StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name).decodeToKey() diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt index e4070d4b60..3f8d2e44cb 100644 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt @@ -5,7 +5,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.tap.common.redux.AppState object DemoHelper { - val config = DemoConfig() + val config = DemoConfig fun isDemoCard(scanResponse: ScanResponse): Boolean = isDemoCardId(scanResponse.card.cardId) diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt index 25964c3070..3a4901f8e3 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt @@ -34,6 +34,7 @@ import org.rekotlin.Action import org.rekotlin.Middleware import timber.log.Timber +@Suppress("MemberNameEqualsClassName") class DetailsMiddleware { private val appSettingsMiddleware = AppSettingsMiddleware() val detailsMiddleware: Middleware = { _, stateProvider -> @@ -66,14 +67,8 @@ class DetailsMiddleware { when (action.setting) { AppSetting.SaveWallets -> toggleSaveWallets(state, enable = action.enable) AppSetting.SaveAccessCode -> toggleSaveAccessCodes(state, enable = action.enable) - AppSetting.RequireAccessCode -> toggleRequireAccessCode( - state = state, - enable = action.enable, - ) - AppSetting.BiometricAuthentication -> toggleBiometricsAuthentication( - state = state, - enable = action.enable, - ) + AppSetting.RequireAccessCode -> toggleRequireAccessCode(enable = action.enable) + AppSetting.BiometricAuthentication -> toggleBiometricsAuthentication(enable = action.enable) } } is DetailsAction.AppSettings.CheckBiometricsStatus -> { @@ -100,7 +95,7 @@ class DetailsMiddleware { } } - private fun toggleBiometricsAuthentication(state: DetailsState, enable: Boolean) { + private fun toggleBiometricsAuthentication(enable: Boolean) { scope.launch { val walletsRepository = store.inject(DaggerGraphState::walletsRepository) @@ -110,16 +105,12 @@ class DetailsMiddleware { return@launch } - toggleRequireAccessCode( - state = state, - enable = true, - ) - if (enable) { setBiometricLockForAllWallets() } else { // Remove all biometric-related data removeAllBiometricData() + walletsRepository.setRequireAccessCode(value = true) } walletsRepository.setUseBiometricAuthentication(value = enable) @@ -127,7 +118,7 @@ class DetailsMiddleware { } } - private fun toggleRequireAccessCode(state: DetailsState, enable: Boolean) { + private fun toggleRequireAccessCode(enable: Boolean) { scope.launch { val walletsRepository = store.inject(DaggerGraphState::walletsRepository) @@ -138,11 +129,8 @@ class DetailsMiddleware { } if (enable) { - // Remove all biometric sign data + // Remove all saved access codes removeAllBiometricSingData() - toggleSaveAccessCodes(state, enable = false) - } else { - toggleSaveAccessCodes(state, enable = true) } walletsRepository.setRequireAccessCode(value = enable) diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt index 5b219a6c22..54a13d6915 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt @@ -111,6 +111,9 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail isHidingEnabled = action.state.isHidingEnabled, selectedAppCurrency = action.state.selectedAppCurrency, selectedThemeMode = action.state.selectedThemeMode, + useBiometricAuthentication = action.state.useBiometricAuthentication, + requireAccessCode = action.state.requireAccessCode, + hasSecuredWallets = action.state.hasSecuredWallets, ), ) is DetailsAction.AppSettings.EnrollBiometrics, diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt index f7e7eaa6dc..518cd67afb 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt @@ -22,6 +22,7 @@ data class AppSettingsState( val requireAccessCode: Boolean = false, val useBiometricAuthentication: Boolean = false, val needEnrollBiometrics: Boolean = false, + val hasSecuredWallets: Boolean = false, val isHidingEnabled: Boolean = false, val isInProgress: Boolean = false, val selectedAppCurrency: AppCurrency = AppCurrency.Default, diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsItemsFactory.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsItemsFactory.kt index bde3cf116f..53b58cdd25 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsItemsFactory.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsItemsFactory.kt @@ -38,6 +38,7 @@ internal class AppSettingsItemsFactory { isChecked: Boolean, isEnabled: Boolean, onCheckedChange: (Boolean) -> Unit, + onDisabledClick: () -> Unit, ): Item.Switch { return Item.Switch( id = ID_USE_BIOMETRICS_SWITCH, @@ -49,6 +50,7 @@ internal class AppSettingsItemsFactory { isEnabled = isEnabled, isChecked = isChecked, onCheckedChange = onCheckedChange, + onDisabledClick = onDisabledClick, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt index 108b30fec8..f73524e227 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt @@ -35,6 +35,7 @@ internal sealed class AppSettingsScreenState { val isEnabled: Boolean, val isChecked: Boolean, val onCheckedChange: (Boolean) -> Unit, + val onDisabledClick: () -> Unit = {}, ) : Item() data class Button( diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt index 153b026e85..29791e12c7 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt @@ -1,12 +1,16 @@ package com.tangem.tap.features.details.ui.appsettings.components import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,35 +43,49 @@ internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier }, ) - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column( - modifier = Modifier.weight(weight = .9f), - verticalArrangement = Arrangement.Center, + Box(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text( - text = item.title.resolveReference(), - style = TangemTheme.typography.subtitle1, - color = titleTextColor, - ) - SpacerH4() - Text( - text = item.description.resolveReference(), - style = TangemTheme.typography.body2, - color = descriptionTextColor, + Column( + modifier = Modifier.weight(weight = .9f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = item.title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = titleTextColor, + ) + SpacerH4() + Text( + text = item.description.resolveReference(), + style = TangemTheme.typography.body2, + color = descriptionTextColor, + ) + } + SpacerW32() + TangemSwitch( + checked = item.isChecked, + enabled = item.isEnabled, + onCheckedChange = item.onCheckedChange, + checkedColor = TangemTheme.colors.control.checked, + uncheckedColor = TangemTheme.colors.icon.inactive, + ) + } + + if (item.isEnabled.not()) { + Box( + Modifier + .matchParentSize() + .clickable( + enabled = true, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = item.onDisabledClick, + ), ) } - SpacerW32() - TangemSwitch( - checked = item.isChecked, - enabled = item.isEnabled, - onCheckedChange = item.onCheckedChange, - checkedColor = TangemTheme.colors.control.checked, - uncheckedColor = TangemTheme.colors.icon.inactive, - ) } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index d9d1ad29f8..c5cc2f6f36 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt @@ -5,11 +5,15 @@ import com.tangem.common.routing.AppRoute 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.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.repository.WalletsRepository @@ -33,6 +37,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.extensions.addIf +import com.tangem.wallet.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* @@ -48,12 +53,14 @@ internal class AppSettingsModel @Inject constructor( private val appCurrencyRepository: AppCurrencyRepository, private val walletsRepository: WalletsRepository, private val canUseBiometryUseCase: CanUseBiometryUseCase, + private val userWalletsListRepository: UserWalletsListRepository, private val balanceHidingRepository: BalanceHidingRepository, private val analyticsEventHandler: AnalyticsEventHandler, private val appThemeModeRepository: AppThemeModeRepository, private val settingsRepository: SettingsRepository, private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender, private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val uiMessageSender: UiMessageSender, ) : Model(), StoreSubscriber { private val itemsFactory = AppSettingsItemsFactory() @@ -114,19 +121,21 @@ internal class AppSettingsModel @Inject constructor( ) if (hotWalletFeatureToggles.isHotWalletEnabled) { - val canUseBiometrics = !state.needEnrollBiometrics && !state.isInProgress + val canUseBiometrics = + !state.needEnrollBiometrics && !state.isInProgress && state.hasSecuredWallets add( itemsFactory.createUseBiometricsSwitch( isChecked = state.useBiometricAuthentication, isEnabled = canUseBiometrics, onCheckedChange = ::onBiometricAuthenticationToggled, + onDisabledClick = ::onBiometricAuthenticationDisabledClicked, ), ) add( itemsFactory.createRequireAccessCodeSwitch( - isChecked = state.requireAccessCode, + isChecked = state.requireAccessCode || !state.useBiometricAuthentication, isEnabled = canUseBiometrics && state.useBiometricAuthentication, onCheckedChange = ::onRequireAccessCodeToggled, ), @@ -206,14 +215,12 @@ internal class AppSettingsModel @Inject constructor( // analyticsEventHandler.send(Settings.AppSettings.BiometricAuthenticationChanged(param)) if (isChecked) { onSettingsToggled(AppSetting.BiometricAuthentication, enable = true) - onSettingsToggled(AppSetting.RequireAccessCode, enable = true) } else { updateContentState { copy( dialog = dialogsFactory.createDisableBiometricAuthenticationAlert( onDisable = { onSettingsToggled(AppSetting.BiometricAuthentication, enable = false) - onSettingsToggled(AppSetting.RequireAccessCode, enable = true) dismissDialog() }, onDismiss = ::dismissDialog, @@ -223,6 +230,10 @@ internal class AppSettingsModel @Inject constructor( } } + private fun onBiometricAuthenticationDisabledClicked() { + uiMessageSender.send(DialogMessage(message = resourceReference(R.string.app_settings_access_code_warning))) + } + private fun onRequireAccessCodeToggled(isChecked: Boolean) { // TODO : Uncomment and implement analytics event when ready // val param = AnalyticsParam.OnOffState(isChecked) @@ -323,6 +334,7 @@ internal class AppSettingsModel @Inject constructor( isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings, selectedAppCurrency = appCurrencyRepository.getSelectedAppCurrency().firstOrNull() ?: AppCurrency.Default, selectedThemeMode = appThemeModeRepository.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT, + hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), ) store.dispatchWithMain(DetailsAction.AppSettings.Prepare(state)) 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..d70fc8ae08 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 @@ -4,9 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tangem.blockchainsdk.BlockchainSDKFactory import com.tangem.common.keyboard.KeyboardValidator -import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.event.TechAnalyticsEvent +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R @@ -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 @@ -35,15 +32,13 @@ import com.tangem.domain.quotes.multi.MultiQuoteUpdater import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase -import com.tangem.domain.staking.FetchStakingTokensUseCase +import com.tangem.domain.staking.FetchStakingOptionsUseCase import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase 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 @@ -67,7 +62,7 @@ internal class MainViewModel @Inject constructor( private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase, private val blockchainSDKFactory: BlockchainSDKFactory, private val dispatchers: CoroutineDispatcherProvider, - private val fetchStakingTokensUseCase: FetchStakingTokensUseCase, + private val fetchStakingOptionsUseCase: FetchStakingOptionsUseCase, private val fetchUserCountryUseCase: FetchUserCountryUseCase, @GlobalUiMessageSender private val messageSender: UiMessageSender, private val keyboardValidator: KeyboardValidator, @@ -83,9 +78,10 @@ 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, + private val trackingContextProxy: TrackingContextProxy, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel() { @@ -108,7 +104,7 @@ internal class MainViewModel @Inject constructor( launch { fetchAppCurrenciesUseCase() } - launch { fetchStakingTokens() } + launch { fetchStakingOptions() } launch { initPushNotifications() } } @@ -181,36 +177,26 @@ internal class MainViewModel @Inject constructor( .mapLeft { emptyFlow() } .onRight { wallet -> wallet.distinctUntilChanged() - .onEach { Analytics.setContext(it) } + .onEach { trackingContextProxy.setContext(it) } .flowOn(dispatchers.io) .launchIn(viewModelScope) } } - private suspend fun fetchStakingTokens() { - fetchStakingTokensUseCase() - .onLeft { Timber.e(it.toString(), "Unable to fetch the staking tokens list") } - .onRight { Timber.d("Staking token list was fetched successfully") } + private suspend fun fetchStakingOptions() { + fetchStakingOptionsUseCase() + .onLeft { Timber.e(it.toString(), "Unable to fetch staking options") } + .onRight { Timber.d("Staking options were fetched successfully") } } 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/features/onboarding/products/wallet/redux/BackupMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt index 2433af1e95..e0e8bd6b12 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/BackupMiddleware.kt @@ -14,6 +14,7 @@ import com.tangem.tap.store import kotlinx.coroutines.launch import org.rekotlin.Middleware +@Suppress("MemberNameEqualsClassName") class BackupMiddleware { val backupMiddleware: Middleware = { dispatch, state -> { next -> diff --git a/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt b/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt index f6e882e5fb..9ca6d43223 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/model/WelcomeModel.kt @@ -77,9 +77,13 @@ internal class WelcomeModel @Inject constructor( warning = warning, error = state.error ?.takeIf { !it.silent && warning == null } - ?.let { e -> - e.messageResId?.let { TextReference.Res(it) } - ?: TextReference.Str(e.customMessage) + ?.let { error -> + val messageResId = error.messageResId + if (messageResId != null) { + TextReference.Res(messageResId) + } else { + TextReference.Str(error.customMessage) + } }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt index 6797731a84..f912f0948a 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt @@ -100,7 +100,10 @@ internal class WelcomeMiddleware { val currency = ParamCardCurrencyConverter().convert( value = scanResponse.cardTypesResolver, ) - Analytics.addContext(scanResponse) + + val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy) + trackingContextProxy.addContext(scanResponse) + if (currency != null) { val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt new file mode 100644 index 0000000000..5839903010 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt @@ -0,0 +1,16 @@ +package com.tangem.tap.network.auth + +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage +import com.tangem.lib.auth.P2PEthPoolAuthProvider + +internal class DefaultP2PEthPoolAuthProvider( + private val environmentConfigStorage: EnvironmentConfigStorage, +) : P2PEthPoolAuthProvider { + + override fun getApiKey(): String { + val keys = environmentConfigStorage.getConfigSync().p2pApiKey + ?: error("No P2P api keys provided") + + return keys.mainnet + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index f1ba2de154..b6f4c2bec8 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -6,10 +6,12 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.tap.network.auth.DefaultAppVersionProvider import com.tangem.tap.network.auth.DefaultAuthProvider import com.tangem.tap.network.auth.DefaultExpressAuthProvider +import com.tangem.tap.network.auth.DefaultP2PEthPoolAuthProvider import com.tangem.tap.network.auth.DefaultStakeKitAuthProvider import com.tangem.utils.version.AppVersionProvider import dagger.Module @@ -50,6 +52,12 @@ internal class AuthModule { return DefaultStakeKitAuthProvider(environmentConfigStorage) } + @Provides + @Singleton + fun provideP2PEthPoolAuthProvider(environmentConfigStorage: EnvironmentConfigStorage): P2PEthPoolAuthProvider { + return DefaultP2PEthPoolAuthProvider(environmentConfigStorage) + } + @Provides @Singleton fun provideAppVersionProvider(): AppVersionProvider { 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..bd69d9835d 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 @@ -22,26 +20,23 @@ import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runCatching +import com.tangem.utils.coroutines.runSuspendCatching 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, ): ScenarioUnavailabilityReason { - val availabilityState = runCatching { getOnrampAvailableState(userWallet.walletId, cryptoCurrency) } + val availabilityState = runSuspendCatching { getOnrampAvailableState(userWallet.walletId, cryptoCurrency) } .getOrNull() ?: ExpressAvailabilityState.Error @@ -56,7 +51,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) }, @@ -90,13 +85,13 @@ internal class DefaultRampManager( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): ScenarioUnavailabilityReason { - val availabilityState = runCatching { + val availabilityState = runSuspendCatching { getExchangeableState(userWalletId, cryptoCurrency) }.getOrNull() ?: ExpressAvailabilityState.Error return availabilityState.toReason(cryptoCurrency.name) } - override fun getSellInitializationStatus(): Flow { + override fun getSellInitializationStatus(): Flow { return sellService.invoke().initializationStatus } @@ -106,8 +101,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 +146,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 +163,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 +206,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..60491ab4c7 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,24 +27,17 @@ 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 - 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) - } + override val initializationStatus: StateFlow + get() = _initializationStatus private var status: MoonPayStatus? = null @@ -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..a42593ea9e 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,7 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Pepecoin, PepecoinTestnet -> null Hyperliquid, HyperliquidTestnet -> null Quai, QuaiTestnet -> null - // Linea, LineaTestnet -> null - // ArbitrumNova -> null + Linea, LineaTestnet -> null + ArbitrumNova -> null + Plasma, PlasmaTestnet -> 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/proxy/redux/DaggerGraphMiddleware.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt index ee884e4e2a..0e822a4edc 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt @@ -3,6 +3,7 @@ package com.tangem.tap.proxy.redux import com.tangem.tap.common.redux.AppState import org.rekotlin.Middleware +@Suppress("MemberNameEqualsClassName") object DaggerGraphMiddleware { val daggerGraphMiddleware: Middleware = { _, _ -> { next -> diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index 0b32197bce..8095c3df87 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -3,6 +3,7 @@ package com.tangem.tap.proxy.redux import com.tangem.blockchainsdk.BlockchainSDKFactory import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.navigation.share.ShareManager @@ -79,4 +80,5 @@ data class DaggerGraphState( val userWalletsListRepository: UserWalletsListRepository? = null, val hotWalletFeatureToggles: HotWalletFeatureToggles? = null, val tangemHotSdk: TangemHotSdk? = null, + val trackingContextProxy: TrackingContextProxy? = null, ) : StateType \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt index 492ca42a8b..ad66a52dce 100644 --- a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt +++ b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt @@ -40,6 +40,12 @@ internal class ProxyAppRouter( } } + override fun replaceCurrent(route: AppRoute, onComplete: (Boolean) -> Unit) { + safeNavigate(onComplete, message = "Replace a current route with $route") { + innerRouter.replaceCurrent(route, onComplete) + } + } + override fun replaceAll(vararg routes: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) { safeNavigate(onComplete, message = "Replace all routes with $routes") { runCatching { diff --git a/app/src/main/java/com/tangem/tap/routing/RootContent.kt b/app/src/main/java/com/tangem/tap/routing/RootContent.kt index 66b66199d4..b95777a48b 100644 --- a/app/src/main/java/com/tangem/tap/routing/RootContent.kt +++ b/app/src/main/java/com/tangem/tap/routing/RootContent.kt @@ -6,6 +6,7 @@ import android.os.Bundle import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -83,6 +84,7 @@ internal fun RootContent( TangemSnackbarHost( modifier = Modifier .align(Alignment.BottomCenter) + .imePadding() .navigationBarsPadding() .padding(all = 16.dp), hostState = snackbarHostState, 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 2059299bb3..5a48848579 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,12 +36,14 @@ 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 +import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent @@ -62,6 +64,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 +103,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,11 +111,13 @@ 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, + private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { @@ -138,7 +144,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 +192,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, @@ -294,7 +308,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, @@ -309,6 +323,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, ) @@ -497,6 +518,13 @@ internal class ChildFactory @Inject constructor( componentFactory = createWalletSelectionComponentFactory, ) } + is AppRoute.CreateHardwareWallet -> { + createComponentChild( + context = context, + params = Unit, + componentFactory = createHardwareWalletComponentFactory, + ) + } is AppRoute.CreateMobileWallet -> { createComponentChild( context = context, @@ -525,6 +553,7 @@ internal class ChildFactory @Inject constructor( context = context, params = WalletActivationComponent.Params( userWalletId = route.userWalletId, + isBackupExists = route.isBackupExists, ), componentFactory = walletActivationComponentFactory, ) @@ -534,6 +563,10 @@ internal class ChildFactory @Inject constructor( context = context, params = CreateWalletBackupComponent.Params( userWalletId = route.userWalletId, + isUpgradeFlow = route.isUpgradeFlow, + shouldSetAccessCode = route.setAccessCode, + analyticsSource = route.analyticsSource, + analyticsAction = route.analyticsAction, ), componentFactory = createWalletBackupComponentFactory, ) @@ -543,6 +576,7 @@ internal class ChildFactory @Inject constructor( context = context, params = UpdateAccessCodeComponent.Params( userWalletId = route.userWalletId, + source = route.source, ), componentFactory = updateAccessCodeComponentFactory, ) @@ -556,6 +590,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, @@ -605,8 +648,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 -> { @@ -622,7 +668,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.Kyc -> { createComponentChild( context = context, - params = KycComponent.Params, + params = KycComponent.Params(route.userWalletId), componentFactory = kycComponentFactory, ) } @@ -637,6 +683,16 @@ internal class ChildFactory @Inject constructor( componentFactory = yieldSupplyPromoComponentFactory, ) } + is AppRoute.YieldSupplyActive -> { + createComponentChild( + context = context, + params = YieldSupplyActiveComponent.Params( + userWalletId = route.userWalletId, + cryptoCurrency = route.cryptoCurrency, + ), + componentFactory = yieldSupplyActiveComponentFactory, + ) + } } } } \ No newline at end of file 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/detekt-baseline-debug.xml b/common/routing/detekt-baseline-debug.xml new file mode 100644 index 0000000000..84f08bf7e3 --- /dev/null +++ b/common/routing/detekt-baseline-debug.xml @@ -0,0 +1,9 @@ + + + + + BooleanPropertyNaming:AppRoute.kt$AppRoute.CreateWalletBackup$val setAccessCode: Boolean = false + NestedScopeFunctions:PayloadToDeeplinkConverter.kt$PayloadToDeeplinkConverter$let { addQueryParam(NAME_KEY, it) } + NestedScopeFunctions:PayloadToDeeplinkConverter.kt$PayloadToDeeplinkConverter$let { addQueryParam(TRANSACTION_ID_KEY, it) } + + 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 d9f6179153..1428522f9f 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 @@ -128,9 +129,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, } } @@ -180,13 +184,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") @@ -194,9 +206,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( @@ -219,6 +231,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") @@ -324,6 +341,9 @@ sealed class AppRoute(val path: String) : Route { } } + @Serializable + object CreateHardwareWallet : AppRoute(path = "/create_hardware_wallet") + @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") @@ -338,16 +358,22 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class WalletActivation( val userWalletId: UserWalletId, + val isBackupExists: Boolean, ) : AppRoute(path = "/wallet_activation/${userWalletId.stringValue}") @Serializable data class CreateWalletBackup( val userWalletId: UserWalletId, + val analyticsSource: String, + val analyticsAction: String, + val isUpgradeFlow: Boolean = false, + val setAccessCode: Boolean = false, ) : AppRoute(path = "/create_wallet_backup/${userWalletId.stringValue}") @Serializable data class UpdateAccessCode( val userWalletId: UserWalletId, + val source: String, ) : AppRoute(path = "/update_access_code/${userWalletId.stringValue}") @Serializable @@ -355,6 +381,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, @@ -385,8 +416,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( @@ -406,7 +438,7 @@ sealed class AppRoute(val path: String) : Route { } @Serializable - data object Kyc : AppRoute(path = "/kyc") + data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") @Serializable data class YieldSupplyPromo( @@ -414,4 +446,11 @@ sealed class AppRoute(val path: String) : Route { val cryptoCurrency: CryptoCurrency, val apy: String, ) : AppRoute(path = "/yield_supply_promo/${userWalletId.stringValue}/${cryptoCurrency.symbol}") + + @Serializable + data class YieldSupplyActive( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val apy: String, + ) : AppRoute(path = "/yield_supply_active/${userWalletId.stringValue}/${cryptoCurrency.symbol}") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRouter.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRouter.kt index 9f8d5f4111..1f03d452d1 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRouter.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRouter.kt @@ -30,6 +30,19 @@ interface AppRouter { }, ) + /** + * Replaces a current route with a new one. + * + * @param route The route to replace a current one. + * @param onComplete The callback to be invoked when the operation is complete. + */ + fun replaceCurrent( + route: AppRoute, + onComplete: (isSuccess: Boolean) -> Unit = { isSuccess -> + defaultCompletionHandler(isSuccess, errorMessage = "Unable to replace a current route with $route") + }, + ) + /** * Replaces ***all*** routes in the navigation stack with the specified [routes]. * diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt index 0761de97a5..cacdcfadba 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt @@ -28,6 +28,12 @@ private class RouterProxy( } } + override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) { + if (route is AppRoute) { + appRouter.replaceCurrent(route, onComplete) + } + } + override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) { routes.filterIsInstance().let { appRouter.replaceAll(*it.toTypedArray(), onComplete = onComplete) diff --git a/common/test/src/main/java/com/tangem/common/test/domain/card/MockScanResponseFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/card/MockScanResponseFactory.kt index 0b04f1cf91..a4a646a6ab 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/card/MockScanResponseFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/card/MockScanResponseFactory.kt @@ -2,11 +2,7 @@ package com.tangem.common.test.domain.card import com.tangem.common.card.CardWallet import com.tangem.common.card.FirmwareVersion -import com.tangem.domain.card.configs.CardConfig -import com.tangem.domain.card.configs.EdSingleCurrencyCardConfig -import com.tangem.domain.card.configs.GenericCardConfig -import com.tangem.domain.card.configs.MultiWalletCardConfig -import com.tangem.domain.card.configs.Wallet2CardConfig +import com.tangem.domain.card.configs.* import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.KeyWalletPublicKey import com.tangem.domain.models.scan.ProductType @@ -73,12 +69,12 @@ object MockScanResponseFactory { isAccessCodeSet = false, isPasscodeSet = null, supportedCurves = emptyList(), - wallets = cardConfig.mandatoryCurves.map { + wallets = cardConfig.mandatoryCurves.map { curve -> CardDTO.Wallet( CardWallet( - publicKey = it.name.toByteArray(), // IMPORTANT: public key must equal to curve name + publicKey = curve.name.toByteArray(), // IMPORTANT: public key must equal to curve name chainCode = null, - curve = it, + curve = curve, settings = createSettings(), totalSignedHashes = null, remainingSignatures = null, @@ -106,6 +102,8 @@ object MockScanResponseFactory { private fun createSettings(): CardWallet.Settings { val constructor = CardWallet.Settings::class.java.declaredConstructors[0] constructor.isAccessible = true - return constructor.newInstance(false) as CardWallet.Settings + + val instance = constructor.newInstance(false) as? CardWallet.Settings + return requireNotNull(instance) } } \ No newline at end of file diff --git a/common/ui-charts/build.gradle.kts b/common/ui-charts/build.gradle.kts index 0520d97bb3..5abca7eada 100644 --- a/common/ui-charts/build.gradle.kts +++ b/common/ui-charts/build.gradle.kts @@ -11,6 +11,7 @@ android { dependencies { /** Project - Core */ implementation(projects.core.ui) + implementation(projects.core.utils) /** Compose */ implementation(tangemDeps.vico.core) diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt index 74adf8e118..3eaa12fe0d 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt @@ -6,6 +6,7 @@ import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel import com.tangem.common.ui.charts.state.converter.PointValuesConverter import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.formatter.FormatterWrapWithCache +import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex @@ -126,7 +127,7 @@ class MarketChartDataProducer private constructor( currentCoroutineContext().ensureActive() - runCatching { + runSuspendCatching { modelProducer.runTransaction { add(LineCartesianLayerModel.Partial(series = listOf(entriesLocal))) } diff --git a/common/ui/build.gradle.kts b/common/ui/build.gradle.kts index 2cb8f794be..9583112c85 100644 --- a/common/ui/build.gradle.kts +++ b/common/ui/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.onramp.models) implementation(projects.domain.promo.models) + implementation(projects.domain.common) implementation(tangemDeps.card.core) implementation(tangemDeps.blockchain) { diff --git a/common/ui/detekt-baseline-debug.xml b/common/ui/detekt-baseline-debug.xml new file mode 100644 index 0000000000..76624db4ad --- /dev/null +++ b/common/ui/detekt-baseline-debug.xml @@ -0,0 +1,32 @@ + + + + + BooleanPropertyNaming:AddTokenUM.kt$AddTokenUM.Button$val showProgress: Boolean + BooleanPropertyNaming:AddTokenUM.kt$AddTokenUM.Network$val editable: Boolean + BooleanPropertyNaming:AmountCurrencyTransformer.kt$AmountCurrencyTransformer$private val value: Boolean + BooleanPropertyNaming:GiveTxPermissionState.kt$CancelPermissionButton$val enabled: Boolean + BooleanPropertyNaming:NotificationUM.kt$NotificationUM.Error.ExceedsBalance$val mergeFeeNetworkName: Boolean = false + BooleanPropertyNaming:NotificationsFactory.kt$NotificationsFactory$val showNotification = sendingAmount + feeAmount > balance - minimumRequirement.orZero() + BooleanPropertyNaming:TokenReceiveBottomSheetConfig.kt$TokenReceiveBottomSheetConfig$val showMemoDisclaimer: Boolean + CanBeNonNullable:AmountBlockV2.kt$onClick: (() -> Unit)? = null + CanBeNonNullable:NavigationButtonsBlock.kt$footerText: TextReference? + CanBeNonNullable:NavigationButtonsBlock.kt$pairButtons: Pair<NavigationButton, NavigationButton>? + CanBeNonNullable:NavigationButtonsBlock.kt$prevButton: NavigationButton? + CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$dustValue: BigDecimal? + CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minAdaValue: BigDecimal? + CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minimumSendAmount: BigDecimal? + CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$rentWarning: CryptoCurrencyWarning.Rent? + MultilineLambdaItParameter:ExpressStatusItems.kt${ val itemInfo = expressTxs[it].info val (iconRes, tint) = when (itemInfo.iconState) { ExpressTransactionStateIconUM.Warning -> { R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention } ExpressTransactionStateIconUM.Error -> { R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning } ExpressTransactionStateIconUM.None -> null to null } ExpressStatusItem( title = itemInfo.title, fromTokenIconState = itemInfo.fromCurrencyIcon, toTokenIconState = itemInfo.toCurrencyIcon, fromAmount = itemInfo.fromAmount, fromSymbol = itemInfo.fromAmountSymbol, toAmount = itemInfo.toAmount, toSymbol = itemInfo.toAmountSymbol, onClick = itemInfo.onClick, infoIconRes = iconRes, infoIconTint = tint, modifier = modifier.animateItem(), ) } + MultilineLambdaItParameter:TokenItemStateConverter.kt$TokenItemStateConverter${ createTitleState(it, yieldModuleApyMap, stakingApyMap, { onApyLabelClick?.invoke(it) }) } + MultilineLambdaItParameter:TokenItemStateConverter.kt$TokenItemStateConverter.Companion${ it.key.equals( other = token.yieldSupplyKey(), ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId), ) } + NamedArguments:TokenItemStateConverter.kt$TokenItemStateConverter$createTitleState(it, yieldModuleApyMap, stakingApyMap, { onApyLabelClick?.invoke(it) }) + NoNameShadowing:NavigationButtonsBlock.kt$navigationUM + NoNameShadowing:UserWalletItem.kt$balance + NullableBooleanCheck:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive ?: false + NullableToStringCall:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$${id.rawCurrencyId} + ReusedModifierInstance:AddTokenContent.kt$AddButton( modifier = modifier.fillMaxWidth(), state = state.button, ) + UnnecessaryEventHandlerParameter:SendDoneButtons.kt$onShareClick: (String) -> Unit + UnnecessaryLet:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$let(::add) + + diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt index e2b2b60cd9..c24dd49e89 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,31 @@ 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.account.AccountId +import com.tangem.domain.models.quote.PriceChange import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import java.math.BigDecimal class AccountCryptoPortfolioItemStateConverter( 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,8 +41,16 @@ 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, + id = account.accountId.toItemId(), iconState = AccountIconItemStateConverter.convert(this), titleState = TokenItemState.TitleState.Content( text = accountName.toUM().value, @@ -50,15 +68,15 @@ 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( - id = account.accountId.value, + private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Content { + return TokenItemState.Content( + id = account.accountId.toItemId(), iconState = AccountIconItemStateConverter.convert(account), titleState = TokenItemState.TitleState.Content( text = accountName.toUM().value, @@ -71,12 +89,16 @@ class AccountCryptoPortfolioItemStateConverter( ), isAvailable = false, ), + fiatAmountState = FiatAmountState.Loading, + subtitle2State = Subtitle2State.Loading, + onItemLongClick = null, + onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, ) } private fun Account.CryptoPortfolio.mapToUnreachableState(): TokenItemState.Unreachable { return TokenItemState.Unreachable( - id = account.accountId.value, + id = account.accountId.toItemId(), iconState = AccountIconItemStateConverter.convert(account), titleState = TokenItemState.TitleState.Content( text = accountName.toUM().value, @@ -97,4 +119,16 @@ class AccountCryptoPortfolioItemStateConverter( }, ) } + + private fun AccountId.toItemId() = this.userWalletId.stringValue + this.value + + 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/AccountIcon.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt index 377c483f00..188fcb1709 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIcon.kt @@ -2,6 +2,7 @@ package com.tangem.common.ui.account import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.common.ui.R import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.account.AccountResIcon @@ -36,6 +37,14 @@ fun AccountIcon( ) { val letter = name.resolveReference().firstOrNull() when { + icon.value == CryptoPortfolioIcon.Icon.Letter && letter == null -> { + AccountResIcon( + resId = R.drawable.ic_tangem_24, + color = icon.color.getUiColor(), + size = size, + modifier = modifier, + ) + } icon.value == CryptoPortfolioIcon.Icon.Letter -> AccountCharIcon( char = letter ?: 'N', color = icon.color.getUiColor(), 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..9b44a22a11 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(val raw: String) : AccountNameUM { override val value: TextReference = stringReference(value = raw) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt index e270e01789..d45ef488d1 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt @@ -10,12 +10,10 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountId -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.converter.Converter class AccountPortfolioItemUMConverter( - private val onClick: (AccountId) -> Unit, + private val onClick: () -> Unit, private val appCurrency: AppCurrency? = null, private val accountBalance: TotalFiatBalance? = null, private val isBalanceHidden: Boolean = false, @@ -26,14 +24,14 @@ class AccountPortfolioItemUMConverter( override fun convert(value: Account): UserWalletItemUM { return with(value) { UserWalletItemUM( - id = UserWalletId(value.accountId.value), - name = value.accountName.toUM().value, - information = getInfo(value), + id = accountId.value, + name = accountName.toUM().value, + information = getInfo(account = this), balance = getBalanceInfo(), isEnabled = isEnabled, endIcon = endIcon, - onClick = { onClick(value.accountId) }, - imageState = getImageState(value), + onClick = onClick, + imageState = getImageState(account = this), ) } } 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/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt similarity index 75% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt rename to common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt index 02464553bd..5e0f916630 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.portfolio.add.impl.ui +package com.tangem.common.ui.addtoken import android.content.res.Configuration import androidx.compose.foundation.background @@ -9,6 +9,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.tangem.core.ui.R import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -22,14 +23,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 @@ -44,12 +45,10 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags import com.tangem.domain.models.account.AccountName -import com.tangem.features.markets.impl.R -import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM import java.util.UUID @Composable -internal fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) { +fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), ) { @@ -63,12 +62,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 +84,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 +194,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 +227,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/state/AddTokenUM.kt b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenUM.kt similarity index 61% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt rename to common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenUM.kt index 7ab163ffb2..5457f68372 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenUM.kt @@ -1,24 +1,16 @@ -package com.tangem.features.markets.portfolio.add.impl.ui.state +package com.tangem.common.ui.addtoken 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/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/common/ui/src/main/java/com/tangem/common/ui/news/ArticleBadge.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleBadge.kt new file mode 100644 index 0000000000..157a36e08d --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleBadge.kt @@ -0,0 +1,76 @@ +package com.tangem.common.ui.news + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun ArticleBadge(articleTagUM: ArticleTagUM, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .heightIn(min = 24.dp) + .background( + color = TangemTheme.colors.icon.informative.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + when (articleTagUM) { + is ArticleTagUM.Category -> Unit + is ArticleTagUM.Token -> { + CurrencyIcon( + state = articleTagUM.iconState, + shouldDisplayNetwork = false, + iconSize = 16.dp, + ) + } + } + Text( + text = articleTagUM.title.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 +private fun ArticleBadgePreview() { + TangemThemePreview { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ArticleBadge( + articleTagUM = ArticleTagUM.Token( + TextReference.Str("BTC"), + iconState = CurrencyIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + ) + ArticleBadge( + articleTagUM = ArticleTagUM.Category(TextReference.Str("Regulation")), + ) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt new file mode 100644 index 0000000000..259c72b98d --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt @@ -0,0 +1,256 @@ +package com.tangem.common.ui.news + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.block.BlockCard +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.utils.StringsSigns +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun ArticleCard(articleConfigUM: ArticleConfigUM, onArticleClick: () -> Unit, modifier: Modifier = Modifier) { + BlockCard( + modifier = modifier, + onClick = onArticleClick, + ) { + if (articleConfigUM.isTrending) { + TrendingArticle(articleConfigUM = articleConfigUM) + } else { + DefaultArticle(articleConfigUM = articleConfigUM) + } + } +} + +@Composable +private fun TrendingArticle(articleConfigUM: ArticleConfigUM) { + Column( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + text = stringResourceSafe(R.string.feed_trending_now), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + ) + + SpacerH(12.dp) + + Text( + text = articleConfigUM.title, + color = if (articleConfigUM.isViewed) { + TangemTheme.colors.text.tertiary + } else { + TangemTheme.colors.text.primary1 + }, + style = TangemTheme.typography.h3, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + + SpacerH(8.dp) + + ArticleInfo( + score = articleConfigUM.score, + createdAt = articleConfigUM.createdAt, + ) + + SpacerH(32.dp) + + Tags( + modifier = Modifier.padding(horizontal = 46.dp), + tags = articleConfigUM.tags.toImmutableList(), + ) + } +} + +@Composable +private fun DefaultArticle(articleConfigUM: ArticleConfigUM) { + Column(modifier = Modifier.padding(12.dp)) { + ArticleInfo( + score = articleConfigUM.score, + createdAt = articleConfigUM.createdAt, + ) + + SpacerH(8.dp) + + Text( + text = articleConfigUM.title, + color = if (articleConfigUM.isViewed) { + TangemTheme.colors.text.tertiary + } else { + TangemTheme.colors.text.primary1 + }, + style = TangemTheme.typography.subtitle1, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + + SpacerH(16.dp) + + Tags(tags = articleConfigUM.tags.toImmutableList()) + } +} + +@Composable +private fun ArticleInfo(score: Float, createdAt: String, modifier: Modifier = Modifier) { + val dotColor = TangemTheme.colors.text.secondary + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_start_circle_12), + contentDescription = null, + ) + + Text( + text = score.toString(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + + Spacer( + modifier = Modifier + .size(4.dp) + .drawWithCache { + val radius = size.minDimension / 2f + onDrawBehind { + drawCircle( + color = dotColor, + radius = radius, + ) + } + }, + ) + + Text( + text = createdAt, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Tags(tags: ImmutableList, modifier: Modifier = Modifier) { + val expandIndicator = remember { + ContextualFlowRowOverflow.expandIndicator { + val remainingItems = tags.size - shownItemCount + ArticleBadge(articleTagUM = ArticleTagUM.Category(TextReference.Str("${StringsSigns.PLUS}$remainingItems"))) + } + } + ContextualFlowRow( + itemCount = tags.size, + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxLines = 1, + overflow = expandIndicator, + ) { index -> + ArticleBadge(articleTagUM = tags[index]) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TagsPreview() { + TangemThemePreview { + Tags( + tags = listOf( + ArticleTagUM.Category(TextReference.Str("Hype")), + ArticleTagUM.Category(TextReference.Str("BTC")), + ArticleTagUM.Category(TextReference.Str("Supply")), + ArticleTagUM.Category(TextReference.Str("Demand")), + ArticleTagUM.Category(TextReference.Str("Best rate")), + ArticleTagUM.Category(TextReference.Str("Breaking news")), + ).toPersistentList(), + ) + } +} + +@Preview(showBackground = true, widthDp = 360, backgroundColor = 0xFF000000) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ArticleCardsPreview() { + val tags = listOf( + ArticleTagUM.Category(TextReference.Str("Hype")), + ArticleTagUM.Category(TextReference.Str("BTC")), + ArticleTagUM.Category(TextReference.Str("Supply")), + ArticleTagUM.Category(TextReference.Str("Demand")), + ArticleTagUM.Category(TextReference.Str("Best rate")), + ArticleTagUM.Category(TextReference.Str("Breaking news")), + ).toImmutableSet() + + val config = ArticleConfigUM( + id = 1, + title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)", + score = 9.5f, + createdAt = "1h ago", + isTrending = true, + tags = tags, + isViewed = false, + ) + + TangemThemePreview { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + ArticleCard( + articleConfigUM = config, + onArticleClick = {}, + ) + + SpacerH(20.dp) + + ArticleCard( + articleConfigUM = config.copy(isViewed = true), + onArticleClick = {}, + ) + + SpacerH(20.dp) + + ArticleCard( + articleConfigUM = config.copy(isTrending = false), + onArticleClick = {}, + ) + + SpacerH(20.dp) + + ArticleCard( + articleConfigUM = config.copy(isTrending = false, isViewed = true), + onArticleClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt new file mode 100644 index 0000000000..e6e9477790 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt @@ -0,0 +1,13 @@ +package com.tangem.common.ui.news + +import kotlinx.collections.immutable.ImmutableSet + +data class ArticleConfigUM( + val id: Int, + val title: String, + val score: Float, + val createdAt: String, + val isTrending: Boolean, + val tags: ImmutableSet, + val isViewed: Boolean, +) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleTagUM.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleTagUM.kt new file mode 100644 index 0000000000..24eb867ad2 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleTagUM.kt @@ -0,0 +1,18 @@ +package com.tangem.common.ui.news + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface ArticleTagUM { + + val title: TextReference + + data class Category(override val title: TextReference) : ArticleTagUM + + data class Token( + override val title: TextReference, + val iconState: CurrencyIconState, + ) : ArticleTagUM +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt index 5f431fee2f..4b35beb47b 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt @@ -37,10 +37,12 @@ import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.StringsSigns.THREE_STARS @@ -322,7 +324,7 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider get() = sequenceOf( UserWalletItemUM( - id = UserWalletId("user_wallet_0".encodeToByteArray()), + id = "user_wallet_0", name = stringReference("Mobile Wallet"), information = getInformation(cardCount = 1), balance = UserWalletItemUM.Balance.Locked, @@ -331,7 +333,7 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider Unit = {}, + onUserCancelled: () -> Unit = {}, + noinline showMessage: (EventMessage) -> Unit, +) { + when (this) { + UnlockWalletError.AlreadyUnlocked -> onAlreadyUnlocked() + UnlockWalletError.ScannedCardWalletNotMatched -> { + showMessage( + DialogMessage( + title = resourceReference(R.string.common_warning), + message = resourceReference(R.string.error_wrong_wallet_tapped), + ), + ) + } + UnlockWalletError.UserCancelled -> onUserCancelled() + UnlockWalletError.UserWalletNotFound -> { + // This should never happen in this flow, as we always check for the wallet existence before unlocking + showMessage(SnackbarMessage(TextReference.Res(R.string.generic_error))) + } + is UnlockWalletError.UnableToUnlock -> handleUnableToUnlock(this, showMessage) + } +} + +fun handleUnableToUnlock(error: UnlockWalletError.UnableToUnlock, showDialog: (DialogMessage) -> Unit) { + val dialogMessage = when (error) { + is UnlockWalletError.UnableToUnlock.WithReason -> { + when (error.reason) { + Reason.AllKeysInvalidated -> { + DialogMessage( + title = resourceReference(R.string.biometric_updated_warning_title), + message = resourceReference(R.string.biometric_updated_warning_description), + ) + } + Reason.BiometricsAuthenticationDisabled -> { + DialogMessage( + title = resourceReference(R.string.biometric_disabled_warning_title), + message = resourceReference(R.string.biometric_disabled_warning_description), + ) + } + is Reason.BiometricsAuthenticationLockout -> { + if ((error.reason as Reason.BiometricsAuthenticationLockout).isPermanent) { + DialogMessage( + title = resourceReference(R.string.biometric_lockout_permanent_warning_title), + message = resourceReference(R.string.biometric_lockout_permanent_warning_description_2), + ) + } else { + DialogMessage( + title = resourceReference(R.string.biometric_lockout_warning_title), + message = resourceReference(R.string.biometric_lockout_warning_description_2), + ) + } + } + } + } + is UnlockWalletError.UnableToUnlock.RawException -> { + DialogMessage( + title = resourceReference(R.string.common_something_went_wrong), + message = stringReference(error.throwable.toString()), + ) + } + UnlockWalletError.UnableToUnlock.Empty -> return + } + + showDialog(dialogMessage) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt index 7add1ddcef..b8f7104b03 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt @@ -32,6 +32,7 @@ class UserWalletItemUMConverter( private val balance: TotalFiatBalance? = null, private val isBalanceHidden: Boolean = false, private val isAuthMode: Boolean = false, + private val isClickableIfLocked: Boolean = false, private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None, artwork: UserWalletItemUM.ImageState? = null, ) : Converter { @@ -41,7 +42,7 @@ class UserWalletItemUMConverter( override fun convert(value: UserWallet): UserWalletItemUM { return with(value) { UserWalletItemUM( - id = walletId, + id = walletId.stringValue, name = stringReference(name), information = getInfo(userWallet = this), balance = getBalanceInfo(userWallet = this), @@ -54,7 +55,7 @@ class UserWalletItemUMConverter( } private fun isEnabled(userWallet: UserWallet): Boolean { - return isAuthMode || userWallet.isLocked.not() + return isAuthMode || isClickableIfLocked || userWallet.isLocked.not() } private fun getInfo(userWallet: UserWallet): UserWalletItemUM.Information.Loaded { diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt index 98c0988741..3c59142510 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt @@ -3,12 +3,11 @@ package com.tangem.common.ui.userwallet.state import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.wallet.UserWalletId import javax.annotation.concurrent.Immutable @Immutable data class UserWalletItemUM( - val id: UserWalletId, + val id: String, val name: TextReference, val information: Information, val balance: Balance, @@ -25,6 +24,7 @@ data class UserWalletItemUM( Warning, } + @Immutable sealed class Balance { data object Hidden : Balance() @@ -43,6 +43,7 @@ data class UserWalletItemUM( ) : Balance() } + @Immutable sealed class Information { data object Failed : Information() diff --git a/core/ab-tests/.gitignore b/core/ab-tests/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core/ab-tests/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/ab-tests/build.gradle.kts b/core/ab-tests/build.gradle.kts new file mode 100644 index 0000000000..d47ebe83f1 --- /dev/null +++ b/core/ab-tests/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + alias(deps.plugins.ksp) + id("configuration") +} + +android { + namespace = "com.tangem.core.abtests" +} + +dependencies { + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Other libraries */ + implementation(deps.timber) + + /** Core modules */ + implementation(projects.core.analytics.models) + implementation(projects.core.datasource) + implementation(projects.core.utils) + + implementation(projects.domain.models) + + /** Amplitude experiment */ + implementation(deps.amplitude.experiment) +} \ No newline at end of file diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt new file mode 100644 index 0000000000..3934f4ba5c --- /dev/null +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt @@ -0,0 +1,40 @@ +package com.tangem.core.abtests.di + +import android.app.Application +import com.tangem.core.abtests.BuildConfig +import com.tangem.core.abtests.manager.ABTestsManager +import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager +import com.tangem.core.abtests.manager.impl.StubABTestsManager +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +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 +@InstallIn(SingletonComponent::class) +internal object ABTestsManagerModule { + + @Provides + @Singleton + fun provideABTestsManager( + application: Application, + environmentConfigStorage: EnvironmentConfigStorage, + dispatchers: CoroutineDispatcherProvider, + ): ABTestsManager { + return if (BuildConfig.AB_TESTS_ENABLED) { + StubABTestsManager() + } else { + AmplitudeABTestsManager( + application = application, + apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().amplitudeApiKey }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + } +} \ No newline at end of file diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/ABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/ABTestsManager.kt new file mode 100644 index 0000000000..07f9253ef9 --- /dev/null +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/ABTestsManager.kt @@ -0,0 +1,12 @@ +package com.tangem.core.abtests.manager + +interface ABTestsManager { + + fun init() + + fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) + + fun removeUserProperties() + + fun getValue(key: String, defaultValue: String): String +} \ No newline at end of file diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt new file mode 100644 index 0000000000..d2b6dbf52c --- /dev/null +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt @@ -0,0 +1,95 @@ +package com.tangem.core.abtests.manager.impl + +import android.app.Application +import com.amplitude.experiment.Experiment +import com.amplitude.experiment.ExperimentClient +import com.amplitude.experiment.ExperimentConfig +import com.amplitude.experiment.ExperimentUser +import com.tangem.core.abtests.manager.ABTestsManager +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.utils.Provider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +internal class AmplitudeABTestsManager( + val application: Application, + val apiKeyProvider: Provider, + val scope: CoroutineScope, +) : ABTestsManager { + + private lateinit var client: ExperimentClient + + override fun init() { + if (::client.isInitialized) { + Timber.w("AB Tests manager already initialized, skipping") + return + } + + client = Experiment.initializeWithAmplitudeAnalytics( + application = application, + apiKey = apiKeyProvider(), + config = ExperimentConfig + .builder() + .automaticFetchOnAmplitudeIdentityChange(true) + .build(), + ) + + scope.launch { + try { + client.fetch().get() + val allVariants = client.all() + logAllVariants(allVariants) + } catch (exception: Exception) { + Timber.e(exception, "Failed to fetch AB test variants") + } + } + } + + override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) { + val userProperties = mutableMapOf() + batch?.let { userProperties[AnalyticsParam.BATCH] = it } + productType?.let { userProperties[AnalyticsParam.PRODUCT_TYPE] = it } + firmware?.let { userProperties[AnalyticsParam.FIRMWARE] = it } + + client.setUser( + ExperimentUser + .builder() + .userId(userId) + .userProperties(userProperties) + .build(), + ) + } + + override fun removeUserProperties() { + client.setUser(ExperimentUser()) + } + + override fun getValue(key: String, defaultValue: String): String { + return client.variant(key).value ?: defaultValue + } + + private fun logAllVariants(allVariants: Map) { + Timber.d("=".repeat(SEPARATOR_LENGTH)) + Timber.d("AB Tests: Fetched ${allVariants.size} variants") + Timber.d("=".repeat(SEPARATOR_LENGTH)) + + if (allVariants.isEmpty()) { + Timber.d("No variants available") + } else { + allVariants.entries.forEachIndexed { index, (key, variant) -> + Timber.d("[${index + 1}/${allVariants.size}] Key: $key") + Timber.d(" → Value: ${variant.value ?: "null"}") + Timber.d(" → Payload: ${variant.payload ?: "null"}") + Timber.d(" → Key: ${variant.key ?: "null"}") + Timber.d("-".repeat(SEPARATOR_LENGTH)) + } + } + + Timber.d("=".repeat(SEPARATOR_LENGTH)) + } + + private companion object { + const val SEPARATOR_LENGTH = 50 + } +} \ No newline at end of file diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/StubABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/StubABTestsManager.kt new file mode 100644 index 0000000000..64aec57be3 --- /dev/null +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/StubABTestsManager.kt @@ -0,0 +1,22 @@ +package com.tangem.core.abtests.manager.impl + +import com.tangem.core.abtests.manager.ABTestsManager + +internal class StubABTestsManager : ABTestsManager { + + override fun init() { + // intentionally do nothing + } + + override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) { + // intentionally do nothing + } + + override fun removeUserProperties() { + // intentionally do nothing + } + + override fun getValue(key: String, defaultValue: String): String { + return defaultValue + } +} \ No newline at end of file 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..47d586e92e 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,11 @@ 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") + data object WalletSettings : ScreensSources("Wallet Settings") + data object Upgrade : ScreensSources("Upgrade") + data object HardwareWallet : ScreensSources("Hardware Wallet") + data object ImportWallet : ScreensSources("Import Wallet") } sealed class TxSentFrom(val value: String) { @@ -202,6 +207,17 @@ sealed class AnalyticsParam { EMPTY("Empty"), FULL("Full") } + enum class ProductType(val value: String) { + Note("Note"), + Twins("Twins"), + Wallet("Wallet"), + Start2Coin("Start2Coin"), + Wallet2("Wallet 2.0"), + Ring("Ring"), + Visa("VISA"), + MobileWallet("Mobile Wallet"), + } + companion object Key { const val BLOCKCHAIN = "Blockchain" const val TOKEN_PARAM = "Token" diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt index a06679e232..9703947954 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt @@ -61,9 +61,9 @@ object Analytics : GlobalAnalyticsEventHandler { return paramsInterceptors.remove(interceptorId) } - override fun setUserId(userWalletId: String) { + override fun setUserId(userId: String) { analyticsScope.launch { - val userIdHash = userWalletId.hexToBytes() + val userIdHash = userId.hexToBytes() .calculateSha256() .toHexString() 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/TrackingContextProxy.kt similarity index 53% rename from core/analytics/src/main/java/com/tangem/core/analytics/utils/AnalyticsContextProxy.kt rename to core/analytics/src/main/java/com/tangem/core/analytics/utils/TrackingContextProxy.kt index cab4da6a7b..23be99e1fd 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/TrackingContextProxy.kt @@ -1,17 +1,26 @@ package com.tangem.core.analytics.utils import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet /** [REDACTED_AUTHOR] */ -interface AnalyticsContextProxy { +interface TrackingContextProxy { fun setContext(scanResponse: ScanResponse) + fun setContext(userWallet: UserWallet) + + 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/build.gradle.kts b/core/config-toggles/build.gradle.kts index 9b23b82a3c..490d2c6fa8 100644 --- a/core/config-toggles/build.gradle.kts +++ b/core/config-toggles/build.gradle.kts @@ -49,12 +49,8 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) + testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) - testImplementation(projects.common.test) } val generateFeatureToggles by tasks.registering { @@ -93,9 +89,9 @@ fun Task.generateToggles(inputFilePath: String, generatedFileName: String) { .add("mapOf(\n") .indent() .apply { - entries.forEachIndexed { index, entry -> + entries.forEach { entry -> add(entry) - if (index != entries.lastIndex) add(",\n") else add("\n") + add(",\n") } } .unindent() @@ -117,5 +113,15 @@ fun Task.generateToggles(inputFilePath: String, generatedFileName: String) { val outputPackageDir = File(outputDir, "") outputPackageDir.mkdirs() fileSpec.writeTo(outputPackageDir) + + // Remove redundant public visibility modifiers + val generatedFile = File(outputPackageDir, "com/tangem/core/configtoggle/$generatedFileName.kt") + if (generatedFile.exists()) { + val content = generatedFile.readText() + val fixedContent = content + .replace("public object ", "object ") + .replace("public val ", "val ") + generatedFile.writeText(fixedContent) + } } } \ No newline at end of file diff --git a/core/config-toggles/detekt-baseline-debug.xml b/core/config-toggles/detekt-baseline-debug.xml new file mode 100644 index 0000000000..b0e03ef827 --- /dev/null +++ b/core/config-toggles/detekt-baseline-debug.xml @@ -0,0 +1,10 @@ + + + + + DoubleMutabilityForCollection:DevExcludedBlockchainsManager.kt$DevExcludedBlockchainsManager$private var blockchainTogglesMap: MutableMap<String, Boolean> by Delegates.notNull() + DoubleMutabilityForCollection:DevFeatureTogglesManager.kt$DevFeatureTogglesManager$private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull() + Indentation:ExcludedBlockchainToggles.kt$ExcludedBlockchainToggles$ + Indentation:FeatureToggles.kt$FeatureToggles$ + + 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..e38b6a0d74 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 @@ -32,7 +32,7 @@ "version": "undefined" }, { - "name": "scroll", + "name": "plasma", "version": "undefined" } ] \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 73c8739caa..1d456f1591 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -19,6 +19,10 @@ "name": "STAKING_CARDANO_ENABLED", "version": "undefined" }, + { + "name": "STAKING_ETH_ENABLED", + "version": "undefined" + }, { "name": "USEDESK_ENABLED", "version": "undefined" @@ -33,7 +37,7 @@ }, { "name": "TANGEM_PAY_ENABLED", - "version": "undefined" + "version": "5.31.0" }, { "name": "NEW_TOKEN_RECEIVE_ENABLED", @@ -50,5 +54,9 @@ { "name": "ACCOUNTS_FEATURE_ENABLED", "version": "undefined" + }, + { + "name": "FEED_ENABLED", + "version": "undefined" } ] diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt index 4970a9c1cf..8e426130a0 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt @@ -1,12 +1,12 @@ package com.tangem.core.configtoggle.manager import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel import com.tangem.core.configtoggle.storage.LocalTogglesStorage import com.tangem.core.configtoggle.version.VersionProvider +import com.tangem.test.core.ProvideTestModels import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.* diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt index ffef24d22e..8d02cfbb5b 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt @@ -1,10 +1,10 @@ package com.tangem.core.configtoggle.manager import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager import com.tangem.core.configtoggle.version.VersionProvider +import com.tangem.test.core.ProvideTestModels import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.* diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 2633b0dc89..8df1d5a9f9 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -90,10 +90,6 @@ dependencies { implementation(deps.room.ktx) ksp(deps.room.compiler) - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) + testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) - testImplementation(projects.common.test) } \ No newline at end of file diff --git a/core/datasource/detekt-baseline-debug.xml b/core/datasource/detekt-baseline-debug.xml new file mode 100644 index 0000000000..7d14c576a1 --- /dev/null +++ b/core/datasource/detekt-baseline-debug.xml @@ -0,0 +1,17 @@ + + + + + CastNullableToNonNullableType:ApiResponseCallAdapterFactory.kt$ApiResponseCallAdapterFactory$as + MultilineLambdaItParameter:DefaultWalletManagersStore.kt$DefaultWalletManagersStore${ it.wallet.blockchain == walletManager.wallet.blockchain && it.wallet.publicKey.derivationPath == walletManager.wallet.publicKey.derivationPath } + NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { amount -> PendingAction.PendingActionArgs.Amount( required = amount.required, minimum = amount.minimum, maximum = amount.maximum, ) } + NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { duration -> PendingAction.PendingActionArgs.Duration( required = duration.required, minimum = duration.minimum, maximum = duration.maximum, ) } + NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { tronResource -> PendingAction.PendingActionArgs.TronResource( required = tronResource.required, options = tronResource.options, ) } + NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withConnectTimeout(timeout = it.duration, unit = it.unit) } + NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withReadTimeout(timeout = it.duration, unit = it.unit) } + NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withWriteTimeout(timeout = it.duration, unit = it.unit) } + UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$apiConfigs + (apiConfig to environment) + UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id } ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI") + UnusedImports:NetworkModule.kt$import com.tangem.datasource.api.common.config.MoonPay + + diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index cac3e4c967..8e0a661871 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 @@ -24,9 +24,13 @@ sealed class ApiConfig { Express, TangemTech, StakeKit, + P2PEthPool, TangemPay, + TangemPayAuth, BlockAid, YieldSupply, + MoonPay, + News, } private fun initializeId(): ID { @@ -34,9 +38,13 @@ sealed class ApiConfig { is Express -> ID.Express is TangemTech -> ID.TangemTech is StakeKit -> ID.StakeKit + is P2PEthPool -> ID.P2PEthPool is TangemPay -> ID.TangemPay + is TangemPayAuth -> ID.TangemPayAuth is BlockAid -> ID.BlockAid is YieldSupply -> ID.YieldSupply + is MoonPay -> ID.MoonPay + is News -> ID.News } } 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/config/News.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt new file mode 100644 index 0000000000..23ff32ba22 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt @@ -0,0 +1,62 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.AuthProvider +import com.tangem.datasource.utils.RequestHeader +import com.tangem.utils.Provider + +/** + * News [ApiConfig] +[REDACTED_AUTHOR] + */ +internal class News( + private val authProvider: AuthProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + createDevEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = PROD_BASE_URL, + headers = createHeaders(ApiEnvironment.PROD), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = DEV_BASE_URL, + headers = createHeaders(ApiEnvironment.DEV), + ) + + private fun createHeaders(environment: ApiEnvironment) = buildMap { + putAll( + RequestHeader.TangemApiKeyHeader( + authProvider = authProvider, + apiEnvironment = Provider { environment }, + ).values, + ) + } + + private companion object { + + private const val PROD_BASE_URL = "https://api.tangem.org/" + private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt new file mode 100644 index 0000000000..b699172b24 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt @@ -0,0 +1,63 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.lib.auth.P2PEthPoolAuthProvider +import com.tangem.utils.ProviderSuspend + +/** + * P2P.org Ethereum Pooled Staking API configuration + */ +internal class P2PEthPool( + private val p2pAuthProvider: P2PEthPoolAuthProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + createTestEnvironment(), + 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.p2p.org/", + headers = createHeaders(), + ) + } + + private fun createTestEnvironment(): ApiEnvironmentConfig { + return ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "https://api-test.p2p.org/", + headers = createHeaders(), + ) + } + + private fun createMockEnvironment(): ApiEnvironmentConfig { + return ApiEnvironmentConfig( + environment = ApiEnvironment.MOCK, + baseUrl = "[REDACTED_ENV_URL]", + headers = createHeaders(), + ) + } + + private fun createHeaders() = buildMap { + put(key = "Authorization", value = ProviderSuspend { "Bearer ${p2pAuthProvider.getApiKey()}" }) + put(key = "accept", value = ProviderSuspend { "application/json" }) + put(key = "Content-Type", value = ProviderSuspend { "application/json" }) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index ef565f5f96..30a3300678 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -31,7 +31,7 @@ internal class TangemPay( private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.DEV, - baseUrl = "[REDACTED_ENV_URL]", + baseUrl = "https://api.dev.us.paera.com/bff/", headers = createHeaders(), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt new file mode 100644 index 0000000000..cbbc29459d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt @@ -0,0 +1,53 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.utils.ProviderSuspend +import com.tangem.utils.version.AppVersionProvider + +internal class TangemPayAuth( + private val appVersionProvider: AppVersionProvider, +) : ApiConfig() { + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs = listOf( + createDevEnvironment(), + createMockedEnvironment(), + createProdEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + -> ApiEnvironment.DEV + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "https://api.dev.us.paera.com/", + headers = createHeaders(), + ) + + private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.MOCK, + baseUrl = "[REDACTED_ENV_URL]", + headers = createHeaders(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.us.paera.com/", + headers = createHeaders(), + ) + + private fun createHeaders() = mapOf( + "version" to ProviderSuspend { appVersionProvider.versionName }, + "platform" to ProviderSuspend { "Android" }, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/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/ethpool/P2PEthPoolApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt new file mode 100644 index 0000000000..60503742bb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt @@ -0,0 +1,119 @@ +package com.tangem.datasource.api.ethpool + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest +import com.tangem.datasource.api.ethpool.models.response.* +import retrofit2.http.* + +/** + * P2P.org Ethereum Pooled Staking API client + * + * Documentation: https://docs.p2p.org/ + * + * Base URL: https://api.p2p.org (prod) / https://api-test.p2p.org (testnet) + */ +interface P2PEthPoolApi { + + /** + * Get list of available vaults + * + * @param network Ethereum pool network: "mainnet" or "hoodi" (testnet) + */ + @GET("api/v1/staking/pool/{network}/vaults") + suspend fun getVaults( + @Path("network") network: String = "mainnet", + ): ApiResponse> + + /** + * Prepare deposit transaction + * + * Create unsigned transaction for depositing ETH into a vault. + * + * @param network Ethereum pool network: "mainnet" or "hoodi" + * @param body Deposit parameters (delegator address, vault address, amount) + */ + @POST("api/v1/staking/pool/{network}/staking/deposit") + suspend fun createDepositTransaction( + @Path("network") network: String, + @Body body: P2PEthPoolDepositRequest, + ): ApiResponse> + + /** + * Prepare unstake transaction + * + * Create unsigned transaction to initiate unstaking process. + * + * @param network Ethereum pool network: "mainnet" or "hoodi" + * @param body Unstake parameters (staker public key, stake transaction hash) + */ + @POST("api/v1/staking/pool/{network}/staking/unstake") + suspend fun createUnstakeTransaction( + @Path("network") network: String, + @Body body: P2PEthPoolUnstakeRequest, + ): ApiResponse> + + /** + * Prepare withdrawal transaction + * + * Create unsigned transaction to withdraw available funds from exit queue. + * + * @param network Ethereum pool network: "mainnet" or "hoodi" + * @param body Withdrawal parameters (staker address) + */ + @POST("api/v1/staking/pool/{network}/staking/withdraw") + suspend fun createWithdrawTransaction( + @Path("network") network: String, + @Body body: P2PEthPoolWithdrawRequest, + ): ApiResponse> + + /** + * Broadcast signed transaction + * + * Submit a signed transaction to the blockchain network. + * + * @param network Ethereum pool network: "mainnet" or "hoodi" + * @param body Signed transaction in hexadecimal format + */ + @POST("api/v1/staking/pool/{network}/transaction/send") + suspend fun broadcastTransaction( + @Path("network") network: String, + @Body body: P2PEthPoolBroadcastRequest, + ): ApiResponse> + + /** + * Get account summary + * + * Retrieve staking balance, rewards, and exit queue information for a specific account and vault. + * + * @param network Ethereum pool network: "mainnet" or "hoodi" + * @param delegatorAddress Account address that initiated staking + * @param vaultAddress Ethereum address of the vault + */ + @GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}") + suspend fun getAccountInfo( + @Path("network") network: String, + @Path("delegatorAddress") delegatorAddress: String, + @Path("vaultAddress") vaultAddress: String, + ): ApiResponse> + + /** + * Get rewards history + * + * Retrieve historical rewards data for a specific account and vault. + * + * @param network Ethereum pool network: "mainnet" or "hoodi" + * @param delegatorAddress Account address that initiated staking + * @param vaultAddress Ethereum address of the vault + * @param period Optional period filter (30, 60, or 90 days) + */ + @GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards") + suspend fun getRewards( + @Path("network") network: String, + @Path("delegatorAddress") delegatorAddress: String, + @Path("vaultAddress") vaultAddress: String, + @Query("period") period: Int? = null, + ): ApiResponse> +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolBroadcastRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolBroadcastRequest.kt new file mode 100644 index 0000000000..822438c42f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolBroadcastRequest.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.ethpool.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for broadcasting signed transaction + * + * Used in: POST /api/v1/staking/pool/{network}/transaction/send + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolBroadcastRequest( + @Json(name = "signedTransaction") + val signedTransaction: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt new file mode 100644 index 0000000000..e4b3200add --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.ethpool.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for creating deposit transaction + * + * Used in: POST /api/v1/staking/pool/{network}/staking/deposit + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolDepositRequest( + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "amount") + val amount: Double, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt new file mode 100644 index 0000000000..0e56cd5112 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt @@ -0,0 +1,20 @@ +package com.tangem.datasource.api.ethpool.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for creating unstake transaction + * + * Used in: POST /api/v1/staking/pool/{network}/staking/unstake + * + * Note: Documentation seems to contain Bitcoin-related fields (possibly copy-paste error). + * Using as-is per specification. + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolUnstakeRequest( + @Json(name = "stakerPublicKey") + val stakerPublicKey: String, + @Json(name = "stakeTransactionHash") + val stakeTransactionHash: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt new file mode 100644 index 0000000000..d0a7aac5cf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.ethpool.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for creating withdrawal transaction + * + * Used in: POST /api/v1/staking/pool/{network}/staking/withdraw + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolWithdrawRequest( + @Json(name = "stakerAddress") + val stakerAddress: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt new file mode 100644 index 0000000000..526c3c7d16 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt @@ -0,0 +1,54 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +/** + * Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress} + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolAccountResponse( + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "stake") + val stake: P2PEthPoolStakeDTO, + @Json(name = "availableToUnstake") + val availableToUnstake: BigDecimal, + @Json(name = "availableToWithdraw") + val availableToWithdraw: BigDecimal, + @Json(name = "exitQueue") + val exitQueue: P2PEthPoolExitQueueDTO, +) + +@JsonClass(generateAdapter = true) +data class P2PEthPoolStakeDTO( + @Json(name = "assets") + val assets: BigDecimal, + @Json(name = "totalEarnedAssets") + val totalEarnedAssets: BigDecimal, +) + +@JsonClass(generateAdapter = true) +data class P2PEthPoolExitQueueDTO( + @Json(name = "total") + val total: Double, + @Json(name = "requests") + val requests: List, +) + +@JsonClass(generateAdapter = true) +data class P2PEthPoolExitRequestDTO( + @Json(name = "ticket") + val ticket: String, + @Json(name = "totalAssets") + val totalAssets: Double, + @Json(name = "timestamp") + val timestamp: Long, + @Json(name = "withdrawalTimestamp") + val withdrawalTimestamp: Long, + @Json(name = "isClaimable") + val isClaimable: Boolean, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt new file mode 100644 index 0000000000..20ec7b67f8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt @@ -0,0 +1,41 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response for POST /api/v1/staking/pool/{network}/transaction/send + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolBroadcastResponse( + @Json(name = "hash") + val hash: String, + @Json(name = "status") + val status: P2PEthPoolTxStatusDTO, + @Json(name = "blockNumber") + val blockNumber: Int, + @Json(name = "transactionIndex") + val transactionIndex: Int, + @Json(name = "gasUsed") + val gasUsed: String, + @Json(name = "cumulativeGasUsed") + val cumulativeGasUsed: String, + @Json(name = "effectiveGasPrice") + val effectiveGasPrice: String?, + @Json(name = "from") + val from: String, + @Json(name = "to") + val to: String, +) + +/** + * Transaction status from P2P API + */ +@JsonClass(generateAdapter = false) +enum class P2PEthPoolTxStatusDTO { + @Json(name = "success") + SUCCESS, + + @Json(name = "failed") + FAILED, +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt new file mode 100644 index 0000000000..ed922bf827 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.joda.time.DateTime + +/** + * Response for POST /api/v1/staking/pool/{network}/staking/deposit + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolDepositResponse( + @Json(name = "amount") + val amount: Double, + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "unsignedTransaction") + val unsignedTransaction: P2PEthPoolUnsignedTxDTO, + @Json(name = "createdAt") + val createdAt: DateTime, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt new file mode 100644 index 0000000000..88235092a1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt @@ -0,0 +1,29 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Error response structure for P2P.org API + * + * All P2P API endpoints return errors in this format + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolErrorResponse( + @Json(name = "error") + val error: P2PEthPoolErrorDetailsDTO, + @Json(name = "result") + val result: Any? = null, // null on error +) + +@JsonClass(generateAdapter = true) +data class P2PEthPoolErrorDetailsDTO( + @Json(name = "code") + val code: Int, // Error code (e.g., 127106, 101111) + @Json(name = "message") + val message: String, // Human-readable error message + @Json(name = "name") + val name: String, // Error name/type + @Json(name = "errors") + val errors: List? = null, // Optional validation errors array +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt new file mode 100644 index 0000000000..e9fe22fd51 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Unified response wrapper for all P2P.org API responses + * + * All P2P API endpoints return responses in this format: + * ```json + * { + * "error": null | { code, message, name, errors }, + * "result": { ... } | null + * } + * ``` + * + * @param T The type of the result data + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolResponse( + @Json(name = "error") + val error: P2PEthPoolErrorDetailsDTO?, + @Json(name = "result") + val result: T?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt new file mode 100644 index 0000000000..ef4dd03ff0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt @@ -0,0 +1,31 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.joda.time.DateTime +import java.math.BigDecimal + +/** + * Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolRewardsResponse( + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "rewards") + val rewards: List, +) + +@JsonClass(generateAdapter = true) +data class P2PEthPoolRewardDTO( + @Json(name = "date") + val date: DateTime, + @Json(name = "apy") + val apy: Double, + @Json(name = "balance") + val balance: BigDecimal, + @Json(name = "rewards") + val rewards: BigDecimal, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnsignedTxDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnsignedTxDTO.kt new file mode 100644 index 0000000000..b391bbebd9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnsignedTxDTO.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +/** + * Unsigned transaction structure + * + * Used in deposit, withdraw responses + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolUnsignedTxDTO( + @Json(name = "serializeTx") + val serializeTx: String, + @Json(name = "to") + val to: String, + @Json(name = "data") + val data: String, + @Json(name = "value") + val value: String, + @Json(name = "nonce") + val nonce: Int, + @Json(name = "chainId") + val chainId: Int, + @Json(name = "gasLimit") + val gasLimit: BigDecimal, + @Json(name = "maxFeePerGas") + val maxFeePerGas: BigDecimal, + @Json(name = "maxPriorityFeePerGas") + val maxPriorityFeePerGas: BigDecimal, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt new file mode 100644 index 0000000000..a3e2391339 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response for POST /api/v1/staking/pool/{network}/staking/unstake + * + * Note: Contains Bitcoin-related fields (likely documentation error). + * Using as-is per specification. + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolUnstakeResponse( + @Json(name = "stakerPublicKey") + val stakerPublicKey: String, + @Json(name = "stakeTransactionHash") + val stakeTransactionHash: String, + @Json(name = "unstakeTransactionHex") + val unstakeTransactionHex: String, // unsigned + @Json(name = "unstakeFee") + val unstakeFee: Double, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt new file mode 100644 index 0000000000..5c825c6346 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt @@ -0,0 +1,59 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response for GET /api/v1/staking/pool/{network}/vaults + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolVaultsResponse( + @Json(name = "network") + val network: P2PEthPoolNetworkDTO, + @Json(name = "vaults") + val vaults: List, +) + +/** + * Network identifier in P2P API + */ +@JsonClass(generateAdapter = false) +enum class P2PEthPoolNetworkDTO { + @Json(name = "mainnet") + MAINNET, + + @Json(name = "hoodi") + HOODI, +} + +@JsonClass(generateAdapter = true) +data class P2PEthPoolVaultDTO( + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "displayName") + val displayName: String, + @Json(name = "apy") + val apy: Double, + @Json(name = "baseApy") + val baseApy: Double, + @Json(name = "capacity") + val capacity: Double, + @Json(name = "totalAssets") + val totalAssets: Double, + @Json(name = "feePercent") + val feePercent: Double, + @Json(name = "isPrivate") + val isPrivate: Boolean, + @Json(name = "isGenesis") + val isGenesis: Boolean, + @Json(name = "isSmoothingPool") + val isSmoothingPool: Boolean, + @Json(name = "isErc20") + val isErc20: Boolean, + @Json(name = "tokenName") + val tokenName: String?, + @Json(name = "tokenSymbol") + val tokenSymbol: String?, + @Json(name = "createdAt") + val createdAt: Long, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt new file mode 100644 index 0000000000..1d5ff5bb4a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.api.ethpool.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.joda.time.DateTime + +/** + * Response for POST /api/v1/staking/pool/{network}/staking/withdraw + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolWithdrawResponse( + @Json(name = "amount") + val amount: Double, + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "unsignedTransaction") + val unsignedTransaction: P2PEthPoolUnsignedTxDTO, + @Json(name = "createdAt") + val createdAt: DateTime, + @Json(name = "tickets") + val tickets: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt deleted file mode 100644 index 984184791c..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.datasource.api.express.models - -object TangemExpressValues { - const val EMPTY_CONTRACT_ADDRESS_VALUE = "0" -} \ No newline at end of file diff --git a/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/news/NewsApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/NewsApi.kt new file mode 100644 index 0000000000..24e359b20e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/NewsApi.kt @@ -0,0 +1,43 @@ +package com.tangem.datasource.api.news + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.news.models.response.NewsCategoriesResponse +import com.tangem.datasource.api.news.models.response.NewsDetailsResponse +import com.tangem.datasource.api.news.models.response.NewsListResponse +import com.tangem.datasource.api.news.models.response.NewsTrendingResponse +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface NewsApi { + + @GET(NEWS_PATH) + suspend fun getNews( + @Query("page") page: Int? = null, + @Query("limit") limit: Int? = null, + @Query("lang") language: String? = null, + @Query("asOf") snapshot: String? = null, + @Query("tokenIds") tokenIds: List? = null, + @Query("categoryIds") categoryIds: List? = null, + ): ApiResponse + + @GET("$NEWS_PATH/{newsId}") + suspend fun getNewsDetails( + @Path("newsId") newsId: Int, + @Query("lang") language: String? = null, + ): ApiResponse + + @GET("$NEWS_PATH/trending") + suspend fun getTrendingNews( + @Query("limit") limit: Int? = null, + @Query("lang") language: String? = null, + ): ApiResponse + + @GET("$NEWS_PATH/categories") + suspend fun getCategories(): ApiResponse + + private companion object { + + private const val NEWS_PATH = "api/v1/news" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt new file mode 100644 index 0000000000..292b3d4559 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt @@ -0,0 +1,41 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsArticleDto( + @Json(name = "id") val id: Int, + @Json(name = "createdAt") val createdAt: String, + @Json(name = "score") val score: Double, + @Json(name = "language") val language: String, + @Json(name = "isTrending") val isTrending: Boolean, + @Json(name = "categories") val categories: List, + @Json(name = "relatedTokens") val relatedTokens: List, + @Json(name = "title") val title: String, + @Json(name = "newsUrl") val newsUrl: String, +) + +@JsonClass(generateAdapter = true) +data class NewsCategoryDto( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String, +) + +@JsonClass(generateAdapter = true) +data class NewsRelatedTokenDto( + @Json(name = "id") val id: String, + @Json(name = "symbol") val symbol: String, + @Json(name = "name") val name: String, +) + +@JsonClass(generateAdapter = true) +data class NewsOriginalArticleDto( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String, + @Json(name = "sourceName") val sourceName: String, + @Json(name = "language") val language: String, + @Json(name = "publishedAt") val publishedAt: String, + @Json(name = "url") val url: String, + @Json(name = "imageUrl") val imageUrl: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsCategoriesResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsCategoriesResponse.kt new file mode 100644 index 0000000000..bc4816f220 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsCategoriesResponse.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsCategoriesResponse( + @Json(name = "items") val items: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt new file mode 100644 index 0000000000..230c3f42fb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt @@ -0,0 +1,20 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsDetailsResponse( + @Json(name = "id") val id: Int, + @Json(name = "createdAt") val createdAt: String, + @Json(name = "score") val score: Double, + @Json(name = "language") val language: String, + @Json(name = "isTrending") val isTrending: Boolean, + @Json(name = "categories") val categories: List, + @Json(name = "relatedTokens") val relatedTokens: List, + @Json(name = "title") val title: String, + @Json(name = "newsUrl") val newsUrl: String, + @Json(name = "shortContent") val shortContent: String, + @Json(name = "content") val content: String, + @Json(name = "originalArticles") val originalArticles: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsListResponse.kt new file mode 100644 index 0000000000..089d974534 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsListResponse.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsListResponse( + @Json(name = "meta") val meta: NewsListMetaDto, + @Json(name = "items") val items: List, +) + +@JsonClass(generateAdapter = true) +data class NewsListMetaDto( + @Json(name = "page") val page: Int, + @Json(name = "limit") val limit: Int, + @Json(name = "total") val total: Long, + @Json(name = "hasNext") val hasNext: Boolean, + @Json(name = "asOf") val asOf: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsTrendingResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsTrendingResponse.kt new file mode 100644 index 0000000000..3a21af3d04 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsTrendingResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsTrendingResponse( + @Json(name = "meta") val meta: NewsTrendingMetaDto, + @Json(name = "items") val items: List, +) + +@JsonClass(generateAdapter = true) +data class NewsTrendingMetaDto( + @Json(name = "limit") val limit: Int, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 7622d4f825..116560b40e 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 @@ -25,11 +26,6 @@ interface TangemPayApi { @Body request: GenerateNoneByCardWalletRequest, ): ApiResponse - @POST("v1/auth/challenge") - suspend fun generateNonceByCustomerWallet( - @Body request: GenerateNonceByCustomerWalletRequest, - ): ApiResponse - @POST("v1/auth/token") suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): ApiResponse @@ -123,6 +119,12 @@ interface TangemPayApi { @GET("v1/customer/me") suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse + @GET("v1/customer/wallets/{customer_wallet_id}") + suspend fun checkCustomerWalletId( + @Header("X-API-KEY") authHeader: String, + @Path("customer_wallet_id") customerWalletId: String, + ): ApiResponse + @POST("v1/deeplink/validate") suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse @@ -146,4 +148,22 @@ 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 + + @POST("v1/customer/card/freeze") + suspend fun freezeCard( + @Header("Authorization") authHeader: String, + @Body body: FreezeUnfreezeCardRequest, + ): ApiResponse + + @POST("v1/customer/card/unfreeze") + suspend fun unfreezeCard( + @Header("Authorization") authHeader: String, + @Body body: FreezeUnfreezeCardRequest, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayAuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayAuthApi.kt new file mode 100644 index 0000000000..fbf512024c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayAuthApi.kt @@ -0,0 +1,28 @@ +package com.tangem.datasource.api.pay + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.pay.models.request.GenerateNonceByCustomerWalletRequest +import com.tangem.datasource.api.pay.models.request.GetTokenByCustomerWalletRequest +import com.tangem.datasource.api.pay.models.request.RefreshCustomerWalletAccessTokenRequest +import com.tangem.datasource.api.pay.models.response.TangemPayGenerateNonceResponse +import com.tangem.datasource.api.pay.models.response.TangemPayGetTokensResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface TangemPayAuthApi { + + @POST("auth/challenge") + suspend fun generateNonceByCustomerWallet( + @Body request: GenerateNonceByCustomerWalletRequest, + ): ApiResponse + + @POST("auth/token") + suspend fun getTokenByCustomerWallet( + @Body request: GetTokenByCustomerWalletRequest, + ): ApiResponse + + @POST("auth/token/refresh") + suspend fun refreshCustomerWalletAccessToken( + @Body request: RefreshCustomerWalletAccessTokenRequest, + ): ApiResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/FreezeUnfreezeCardRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/FreezeUnfreezeCardRequest.kt new file mode 100644 index 0000000000..c0103ec683 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/FreezeUnfreezeCardRequest.kt @@ -0,0 +1,7 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class FreezeUnfreezeCardRequest(@Json(name = "card_id") val cardId: String) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNonceByCustomerWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNonceByCustomerWalletRequest.kt index edd8260545..882f725799 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNonceByCustomerWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GenerateNonceByCustomerWalletRequest.kt @@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass data class GenerateNonceByCustomerWalletRequest( @Json(name = "auth_type") val authType: String = "customer_wallet", @Json(name = "customer_wallet_address") val customerWalletAddress: String, + @Json(name = "customer_wallet_id") val customerWalletId: String, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetTokenByCustomerWalletRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetTokenByCustomerWalletRequest.kt index 9a5e47e327..0001d3a3f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetTokenByCustomerWalletRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/GetTokenByCustomerWalletRequest.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class GetTokenByCustomerWalletRequest( - @Json(name = "auth_type") val authType: String = "customer_wallet", + @Json(name = "auth_type") val authType: String, @Json(name = "session_id") val sessionId: String, @Json(name = "signature") val signature: String, @Json(name = "message_format") val messageFormat: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt index d4b509360f..d5d297612f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt @@ -5,6 +5,6 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class RefreshCustomerWalletAccessTokenRequest( - @Json(name = "auth_type") val authType: String = "customer_wallet", + @Json(name = "auth_type") val authType: String, @Json(name = "refresh_token") val refreshToken: String, ) \ 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/pay/models/response/BalanceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BalanceResponse.kt new file mode 100644 index 0000000000..50f759c8c2 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BalanceResponse.kt @@ -0,0 +1,37 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +@JsonClass(generateAdapter = true) +data class BalanceResponse( + @Json(name = "fiat") val fiat: FiatBalance, + @Json(name = "crypto") val crypto: CryptoBalance, + @Json(name = "available_for_withdrawal") val availableForWithdrawal: AvailableForWithdrawal, +) + +@JsonClass(generateAdapter = true) +data class FiatBalance( + @Json(name = "currency") val currency: String, + @Json(name = "available_balance") val availableBalance: BigDecimal, + @Json(name = "credit_limit") val creditLimit: BigDecimal, + @Json(name = "pending_charges") val pendingCharges: BigDecimal, + @Json(name = "posted_charges") val postedCharges: BigDecimal, + @Json(name = "balance_due") val balanceDue: BigDecimal, +) + +@JsonClass(generateAdapter = true) +data class CryptoBalance( + @Json(name = "id") val id: String, + @Json(name = "chain_id") val chainId: Int, + @Json(name = "deposit_address") val depositAddress: String, + @Json(name = "token_contract_address") val tokenContractAddress: String, + @Json(name = "balance") val balance: BigDecimal, +) + +@JsonClass(generateAdapter = true) +data class AvailableForWithdrawal( + @Json(name = "amount") val amount: BigDecimal, + @Json(name = "currency") val currency: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardBalanceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardBalanceResponse.kt index c37d3931b3..164915f64d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardBalanceResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardBalanceResponse.kt @@ -1,20 +1,8 @@ package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import java.math.BigDecimal data class CardBalanceResponse( - @Json(name = "result") val result: Result?, + @Json(name = "result") val result: BalanceResponse?, @Json(name = "error") val error: String?, -) { - @JsonClass(generateAdapter = true) - data class Result( - @Json(name = "currency") val currency: String, - @Json(name = "available_balance") val availableBalance: BigDecimal, - @Json(name = "credit_limit") val creditLimit: BigDecimal, - @Json(name = "pending_charges") val pendingCharges: BigDecimal, - @Json(name = "posted_charges") val postedCharges: BigDecimal, - @Json(name = "balance_due") val balanceDue: BigDecimal, - ) -} \ No newline at end of file +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CheckCustomerWalletResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CheckCustomerWalletResponse.kt new file mode 100644 index 0000000000..11f891c668 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CheckCustomerWalletResponse.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CheckCustomerWalletResponse( + @Json(name = "id") val id: String?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 65a28d997e..fd89823bea 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -2,7 +2,6 @@ package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import java.math.BigDecimal @JsonClass(generateAdapter = true) data class CustomerMeResponse( @@ -19,7 +18,7 @@ data class CustomerMeResponse( @Json(name = "kyc") val kyc: Kyc?, @Json(name = "depositAddress") val depositAddress: String?, @Json(name = "card") val card: Card?, - @Json(name = "balance") val balance: Balance?, + @Json(name = "balance") val balance: BalanceResponse?, ) @JsonClass(generateAdapter = true) @@ -28,10 +27,49 @@ data class CustomerMeResponse( @Json(name = "cid") val cid: String, @Json(name = "card_id") val cardId: String, @Json(name = "card_wallet_address") val cardWalletAddress: String, - @Json(name = "status") val status: String, + @Json(name = "status") val status: Status, @Json(name = "updated_at") val updatedAt: String, @Json(name = "payment_account_id") val paymentAccountId: String, - ) + ) { + @JsonClass(generateAdapter = false) + enum class Status { + @Json(name = "new") + NEW, + + @Json(name = "ready_for_manufacturing") + READY_FOR_MANUFACTURING, + + @Json(name = "manufacturing") + MANUFACTURING, + + @Json(name = "sent_to_delivery") + SENT_TO_DELIVERY, + + @Json(name = "delivered") + DELIVERED, + + @Json(name = "activating") + ACTIVATING, + + @Json(name = "active") + ACTIVE, + + @Json(name = "blocked") + BLOCKED, + + @Json(name = "deactivating") + DEACTIVATING, + + @Json(name = "deactivated") + DEACTIVATED, + + @Json(name = "canceled") + CANCELED, + + @Json(name = "unknown") + UNKNOWN, + } + } @JsonClass(generateAdapter = true) data class PaymentAccount( @@ -60,14 +98,4 @@ data class CustomerMeResponse( @Json(name = "card_status") val cardStatus: String, @Json(name = "card_number_end") val cardNumberEnd: String, ) - - @JsonClass(generateAdapter = true) - data class Balance( - @Json(name = "currency") val currency: String, - @Json(name = "available_balance") val availableBalance: BigDecimal, - @Json(name = "credit_limit") val creditLimit: BigDecimal, - @Json(name = "pending_charges") val pendingCharges: BigDecimal, - @Json(name = "posted_charges") val postedCharges: BigDecimal, - @Json(name = "balance_due") val balanceDue: BigDecimal, - ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FreezeUnfreezeCardResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FreezeUnfreezeCardResponse.kt new file mode 100644 index 0000000000..7ec1155bdc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FreezeUnfreezeCardResponse.kt @@ -0,0 +1,31 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class FreezeUnfreezeCardResponse( + @Json(name = "result") val result: Result?, + @Json(name = "error") val error: String?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "order_id") val orderId: String, + @Json(name = "status") val status: Status, + ) + + @JsonClass(generateAdapter = false) + enum class Status { + @Json(name = "NEW") + NEW, + + @Json(name = "PROCESSING") + PROCESSING, + + @Json(name = "COMPLETED") + COMPLETED, + + @Json(name = "CANCELED") + CANCELED, + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayGenerateNonceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayGenerateNonceResponse.kt new file mode 100644 index 0000000000..95d6460001 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayGenerateNonceResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TangemPayGenerateNonceResponse( + @Json(name = "nonce") val nonce: String, + @Json(name = "session_id") val sessionId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayGetTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayGetTokensResponse.kt new file mode 100644 index 0000000000..8fb5eaeb64 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayGetTokensResponse.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TangemPayGetTokensResponse( + @Json(name = "access_token") val accessToken: String, + @Json(name = "expires_at") val expiresAt: Long, + @Json(name = "refresh_token") val refreshToken: String, + @Json(name = "refresh_expires_at") val refreshExpiresAt: Long, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayTxHistoryResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayTxHistoryResponse.kt index 30fedbe52a..b7ccfdeb67 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayTxHistoryResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/TangemPayTxHistoryResponse.kt @@ -37,7 +37,7 @@ data class TangemPayTxHistoryResponse( @Json(name = "memo") val memo: String? = null, @Json(name = "receipt") val receipt: Boolean, @Json(name = "merchant_name") val merchantName: String, - @Json(name = "merchant_category") val merchantCategory: String, + @Json(name = "merchant_category") val merchantCategory: String?, @Json(name = "merchant_category_code") val merchantCategoryCode: String, @Json(name = "merchant_id") val merchantId: String? = null, @Json(name = "enriched_merchant_icon") val enrichedMerchantIcon: String? = null, 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 89e68bbe2e..d3e09a5f52 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 @@ -50,9 +50,6 @@ interface TangemTechApi { @Body userTokens: UserTokensResponse, ): ApiResponse - @POST("v1/user-tokens") - suspend fun markUserWallerWasCreated(@Body body: MarkUserWalletWasCreatedBody): ApiResponse - /** Returns referral status by [walletId] */ @GET("v1/referral/{walletId}") suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse @@ -137,6 +134,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: WalletIdBody): ApiResponse // endregion // promo diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/converters/WalletIdBodyConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt similarity index 52% rename from data/wallets/src/main/java/com/tangem/data/wallets/converters/WalletIdBodyConverter.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt index fe6f1429a5..0e65ff47c9 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/converters/WalletIdBodyConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt @@ -1,19 +1,21 @@ -package com.tangem.data.wallets.converters +package com.tangem.datasource.api.tangemTech.converters import com.tangem.datasource.api.tangemTech.models.CardInfoBody import com.tangem.datasource.api.tangemTech.models.WalletIdBody +import com.tangem.datasource.api.tangemTech.models.WalletType import com.tangem.domain.models.wallet.UserWallet -internal object WalletIdBodyConverter { +object WalletIdBodyConverter { - fun convert(userWallet: UserWallet, publicKeys: Map): WalletIdBody { + fun convert(userWallet: UserWallet, publicKeys: Map? = null): WalletIdBody { return WalletIdBody( walletId = userWallet.walletId.stringValue, name = userWallet.name, - cards = publicKeys.map { + walletType = WalletType.from(userWallet), + cards = publicKeys?.map { publicKeyById -> CardInfoBody( - cardId = it.key, - cardPublicKey = it.value, + cardId = publicKeyById.key, + cardPublicKey = publicKeyById.value, ) }, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/MarkUserWalletWasCreatedBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/MarkUserWalletWasCreatedBody.kt deleted file mode 100644 index 0c4d2a5bd8..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/MarkUserWalletWasCreatedBody.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.datasource.api.tangemTech.models - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class MarkUserWalletWasCreatedBody( - @Json(name = "user_wallet_id") val userWalletId: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index c5f172e68c..c9602c0247 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -3,6 +3,9 @@ package com.tangem.datasource.api.tangemTech.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.common.extensions.calculateHashCode +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType.NONE +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.SortType @JsonClass(generateAdapter = true) @Suppress("BooleanPropertyNaming") @@ -10,8 +13,10 @@ data class UserTokensResponse( @Json(name = "version") val version: Int = 0, @Json(name = "group") val group: GroupType, @Json(name = "sort") val sort: SortType, - @Json(name = "notifyStatus") val notifyStatus: Boolean? = null, @Json(name = "tokens") val tokens: List = emptyList(), + @Json(name = "notifyStatus") val notifyStatus: Boolean? = null, + @Json(name = "name") val walletName: String? = null, + @Json(name = "type") val walletType: WalletType? = null, ) { @JsonClass(generateAdapter = true) @@ -68,4 +73,8 @@ data class UserTokensResponse( @Json(name = "marketcap") MARKETCAP, } -} \ No newline at end of file +} + +fun GroupType?.orDefault(): GroupType = this ?: NONE + +fun SortType?.orDefault(): SortType = this ?: SortType.MANUAL \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt index 806199d729..87a2d7a563 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt @@ -4,8 +4,7 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) -@Suppress("BooleanPropertyNaming") data class WalletBody( - @Json(name = "notifyStatus") val notifyStatus: Boolean? = null, @Json(name = "name") val name: String? = null, + @Json(name = "type") val type: WalletType? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt index 3fd5ff6196..cb4bd5f2df 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt @@ -7,5 +7,6 @@ import com.squareup.moshi.JsonClass data class WalletIdBody( @Json(name = "id") val walletId: String, @Json(name = "name") val name: String, - @Json(name = "cards") val cards: List, + @Json(name = "type") val walletType: WalletType? = null, + @Json(name = "cards") val cards: List? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletType.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletType.kt new file mode 100644 index 0000000000..417d95162b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletType.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.domain.models.wallet.UserWallet + +@JsonClass(generateAdapter = false) +enum class WalletType { + @Json(name = "card") + COLD, + + @Json(name = "mobile") + HOT, + ; + + companion object { + + fun from(userWallet: UserWallet?): WalletType? { + return when (userWallet) { + is UserWallet.Cold -> COLD + is UserWallet.Hot -> HOT + null -> null + } + } + } +} \ 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..757436e0fc 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 @@ -15,9 +15,23 @@ data class GetWalletAccountsResponse( @JsonClass(generateAdapter = true) data class Wallet( - @Json(name = "version") val version: Int = 0, - @Json(name = "group") val group: GroupType, - @Json(name = "sort") val sort: SortType, + @Json(name = "version") val version: Int? = 0, + @Json(name = "group") val group: GroupType?, + @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 ?: GroupType.NONE, + sort = wallet.sort ?: SortType.MANUAL, + tokens = flattenTokens(), + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AccountsMigrationStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AccountsMigrationStoreModule.kt new file mode 100644 index 0000000000..f97fb1dc72 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AccountsMigrationStoreModule.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.accounts.AccountTokenMigrationStore +import com.tangem.datasource.local.accounts.DefaultAccountTokenMigrationStore +import com.tangem.datasource.local.datastore.RuntimeStateStore +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 AccountsMigrationStoreModule { + + @Provides + @Singleton + fun provideAccountTokenMigrationStore(): AccountTokenMigrationStore { + return DefaultAccountTokenMigrationStore( + runtimeStateStore = RuntimeStateStore(emptyMap()), + ) + } +} \ 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..7bf7ad5ce5 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 @@ -4,6 +4,7 @@ import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.config.* import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.utils.info.AppInfoProvider import com.tangem.utils.version.AppVersionProvider @@ -39,6 +40,12 @@ internal object ApiConfigsModule { return StakeKit(stakeKitAuthProvider) } + @Provides + @IntoSet + fun provideP2PEthPoolConfig(p2pAuthProvider: P2PEthPoolAuthProvider): ApiConfig { + return P2PEthPool(p2pAuthProvider) + } + @Provides @IntoSet fun provideTangemTechConfig( @@ -51,6 +58,10 @@ internal object ApiConfigsModule { appInfoProvider = appInfoProvider, ) + @Provides + @IntoSet + fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider) + @Provides @IntoSet fun provideYieldSupplyConfig( @@ -69,9 +80,21 @@ internal object ApiConfigsModule { @IntoSet fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPay(appVersionProvider) + @Provides + @IntoSet + fun provideTangemPayAuthConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPayAuth( + appVersionProvider, + ) + @Provides @IntoSet 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..6c07212416 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,14 +5,19 @@ 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.news.NewsApi import com.tangem.datasource.api.onramp.OnrampApi +import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.YieldSupplyApi @@ -71,6 +76,15 @@ internal object NetworkModule { ) } + @Provides + @Singleton + fun provideP2PEthPoolApi(retrofitApiBuilder: RetrofitApiBuilder): P2PEthPoolApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.P2PEthPool, + applyTimeoutAnnotations = false, + ) + } + @Provides @Singleton fun provideOnrampApi(retrofitApiBuilder: RetrofitApiBuilder): OnrampApi { @@ -122,6 +136,15 @@ internal object NetworkModule { ) } + @Provides + @Singleton + fun provideTangemPayAuthApi(retrofitApiBuilder: RetrofitApiBuilder): TangemPayAuthApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.TangemPayAuth, + applyTimeoutAnnotations = false, + ) + } + @Provides @Singleton fun provideBlockAidApi(retrofitApiBuilder: RetrofitApiBuilder): BlockAidApi { @@ -130,4 +153,22 @@ internal object NetworkModule { applyTimeoutAnnotations = false, ) } + + @Provides + @Singleton + fun provideMoonPayApi(retrofitApiBuilder: RetrofitApiBuilder): MoonPayApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.MoonPay, + applyTimeoutAnnotations = false, + ) + } + + @Provides + @Singleton + fun provideNewsApi(retrofitApiBuilder: RetrofitApiBuilder): NewsApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.News, + applyTimeoutAnnotations = false, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt new file mode 100644 index 0000000000..fa1dbd3792 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt @@ -0,0 +1,29 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore +import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore +import com.tangem.datasource.local.news.trending.TrendingNewsStore +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 NewsStoreModule { + + @Provides + @Singleton + fun provideNewsDetailsStore(): NewsDetailsStore { + return DefaultNewsDetailsStore(store = RuntimeSharedStore()) + } + + @Provides + @Singleton + fun provideTrendingNewsStore(): TrendingNewsStore { + return DefaultTrendingNewsStore(store = RuntimeSharedStore()) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt index c946177dc7..695c1c8d45 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt @@ -8,10 +8,13 @@ import com.squareup.moshi.Moshi import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.token.DefaultP2PEthPoolVaultsStore import com.tangem.datasource.local.token.DefaultStakingActionsStore import com.tangem.datasource.local.token.DefaultStakingYieldsStore +import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.StakingActionsStore import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes import com.tangem.datasource.utils.mapWithStringKeyTypes @@ -73,4 +76,24 @@ internal object StakingStoreModule { fun provideStakingActionsStore(): StakingActionsStore { return DefaultStakingActionsStore(dataStore = RuntimeDataStore()) } + + @Provides + @Singleton + fun provideP2PEthPoolVaultsStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): P2PEthPoolVaultsStore { + return DefaultP2PEthPoolVaultsStore( + dataStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = listTypes(), + defaultValue = emptyList(), + ), + produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_vaults") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt new file mode 100644 index 0000000000..ae3097d006 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore +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 TangemPayStoresModule { + + @Provides + @Singleton + fun provideTangemPayCardFrozenStateStore(): TangemPayCardFrozenStateStore { + return DefaultTangemPayCardFrozenStateStore( + dataStore = RuntimeDataStore(), + ) + } +} \ 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/accounts/AccountTokenMigrationStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/accounts/AccountTokenMigrationStore.kt new file mode 100644 index 0000000000..c527ef5dec --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/accounts/AccountTokenMigrationStore.kt @@ -0,0 +1,13 @@ +package com.tangem.datasource.local.accounts + +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface AccountTokenMigrationStore { + + fun get(userWalletId: UserWalletId): Flow?> + + suspend fun store(userWalletId: UserWalletId, value: Pair) + + suspend fun remove(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/accounts/DefaultAccountTokenMigrationStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/accounts/DefaultAccountTokenMigrationStore.kt new file mode 100644 index 0000000000..920b4ed166 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/accounts/DefaultAccountTokenMigrationStore.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.local.accounts + +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class DefaultAccountTokenMigrationStore( + private val runtimeStateStore: RuntimeStateStore>>, +) : AccountTokenMigrationStore { + + override fun get(userWalletId: UserWalletId): Flow?> { + return runtimeStateStore.get().map { it[userWalletId] } + } + + override suspend fun store(userWalletId: UserWalletId, value: Pair) { + runtimeStateStore.update { it + (userWalletId to value) } + } + + override suspend fun remove(userWalletId: UserWalletId) { + runtimeStateStore.update { it - userWalletId } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index 252782f20a..cce8979a7c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.local.config.environment import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.datasource.local.config.environment.models.ExpressModel +import com.tangem.datasource.local.config.environment.models.P2PKeys data class EnvironmentConfig( val moonPayApiKey: String = "", @@ -16,10 +17,12 @@ data class EnvironmentConfig( val express: ExpressModel? = null, val devExpress: ExpressModel? = null, val stakeKitApiKey: String? = null, + val p2pApiKey: P2PKeys? = null, val blockAidApiKey: String? = null, val tangemApiKey: String? = null, val tangemApiKeyDev: String? = null, val tangemApiKeyStage: String? = null, val yieldModuleApiKey: String? = null, val yieldModuleApiKeyDev: String? = null, + val bffStaticToken: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/BlockchainSDKConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/BlockchainSDKConfigConverter.kt index f34e1f8c3e..6d26e8f55f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/BlockchainSDKConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/BlockchainSDKConfigConverter.kt @@ -26,6 +26,10 @@ internal object BlockchainSDKConfigConverter : Converter : DataStore \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/details/DefaultNewsDetailsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/DefaultNewsDetailsStore.kt new file mode 100644 index 0000000000..06eac03937 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/DefaultNewsDetailsStore.kt @@ -0,0 +1,37 @@ +package com.tangem.datasource.local.news.details + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.news.DetailedArticle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class DefaultNewsDetailsStore( + private val store: RuntimeSharedStore>, +) : NewsDetailsStore { + + override fun getAll(): Flow> { + return store.get().map { it.values.toList() } + } + + override suspend fun getSyncOrNull(id: Int): DetailedArticle? { + return store.getSyncOrNull()?.get(id) + } + + override suspend fun store(id: Int, article: DetailedArticle) { + store.update(emptyMap()) { current -> + current + (id to article) + } + } + + override suspend fun store(articles: Map) { + if (articles.isEmpty()) return + + store.update(emptyMap()) { current -> + current + articles + } + } + + override suspend fun clear() { + store.store(emptyMap()) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/details/NewsDetailsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/NewsDetailsStore.kt new file mode 100644 index 0000000000..f2af3e04ab --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/NewsDetailsStore.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.local.news.details + +import com.tangem.domain.models.news.DetailedArticle +import kotlinx.coroutines.flow.Flow + +interface NewsDetailsStore { + + fun getAll(): Flow> + + suspend fun getSyncOrNull(id: Int): DetailedArticle? + + suspend fun store(id: Int, article: DetailedArticle) + + suspend fun store(articles: Map) + + suspend fun clear() +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt new file mode 100644 index 0000000000..23c1cad1db --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt @@ -0,0 +1,31 @@ +package com.tangem.datasource.local.news.trending + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.news.ShortArticle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private typealias TrendingCache = Map> + +internal class DefaultTrendingNewsStore( + private val store: RuntimeSharedStore, +) : TrendingNewsStore { + + override fun get(key: String): Flow> { + return store.get().map { it[key].orEmpty() } + } + + override suspend fun getSyncOrNull(key: String): List? { + return store.getSyncOrNull()?.get(key) + } + + override suspend fun store(key: String, value: List) { + store.update(emptyMap()) { current -> + current + (key to value) + } + } + + override suspend fun clear() { + store.store(emptyMap()) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt new file mode 100644 index 0000000000..24afaabda0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.local.news.trending + +import com.tangem.domain.models.news.ShortArticle +import kotlinx.coroutines.flow.Flow + +interface TrendingNewsStore { + + fun get(key: String): Flow> + + suspend fun getSyncOrNull(key: String): List? + + suspend fun store(key: String, value: List) + + suspend fun clear() +} \ 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..fe34d263a9 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 @@ -13,6 +13,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_K import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_APPLICATION_STOPPED_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN +import com.tangem.domain.models.wallet.UserWalletId /** * All preferences keys that DataStore is stored. @@ -121,6 +122,8 @@ object PreferencesKeys { val YIELD_SUPPLY_WARNINGS_STATES_KEY by lazy { stringPreferencesKey(name = "yieldSupplyWarningsStates") } + val ACCESS_CODE_SKIPPED_STATES_KEY by lazy { stringPreferencesKey(name = "accessCodeSkippedStates") } + // region Notifications val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } @@ -171,6 +174,12 @@ object PreferencesKeys { fun getHotWalletUnlockDeadlineKey(attemptId: String) = longPreferencesKey(name = "hotWalletUnlockDeadline_$attemptId") + fun getTangemPayAddToWalletKey(customerWalletAddress: String) = + booleanPreferencesKey("tangem_pay_add_to_wallet_done_key_$customerWalletAddress") + + fun getTangemPayCheckCustomerByWalletId(userWalletId: UserWalletId) = + booleanPreferencesKey("tangem_pay_check_customer_by_wallet_id_$userWalletId") + // endregion } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultP2PEthPoolVaultsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultP2PEthPoolVaultsStore.kt new file mode 100644 index 0000000000..f48e2457ad --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultP2PEthPoolVaultsStore.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.local.token + +import androidx.datastore.core.DataStore +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull + +internal class DefaultP2PEthPoolVaultsStore( + private val dataStore: DataStore>, +) : P2PEthPoolVaultsStore { + + override fun get(): Flow> { + return dataStore.data + } + + override suspend fun getSync(): List { + return dataStore.data.firstOrNull().orEmpty() + } + + override suspend fun store(vaults: List) { + dataStore.updateData { vaults } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolVaultsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolVaultsStore.kt new file mode 100644 index 0000000000..a99eba1730 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolVaultsStore.kt @@ -0,0 +1,29 @@ +package com.tangem.datasource.local.token + +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import kotlinx.coroutines.flow.Flow + +/** + * Store for P2P Ethereum pooled staking vaults + * (similar to StakingYieldsStore for StakeKit yields) + * + * Vault is ETH-specific concept for pooled staking. + * For other blockchains, P2P may use different structures. + */ +interface P2PEthPoolVaultsStore { + + /** + * Get all stored vaults as Flow + */ + fun get(): Flow> + + /** + * Get all stored vaults synchronously + */ + suspend fun getSync(): List + + /** + * Store vaults from P2P API + */ + suspend fun store(vaults: List) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayCardFrozenStateStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayCardFrozenStateStore.kt new file mode 100644 index 0000000000..4eefede01a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayCardFrozenStateStore.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.local.visa + +import com.tangem.datasource.local.datastore.core.StringKeyDataStore +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import kotlinx.coroutines.flow.Flow + +internal class DefaultTangemPayCardFrozenStateStore( + private val dataStore: StringKeyDataStore, +) : TangemPayCardFrozenStateStore { + override suspend fun getSyncOrNull(key: String): TangemPayCardFrozenState? { + return dataStore.getSyncOrNull(key) + } + + override fun get(key: String): Flow { + return dataStore.get(key) + } + + override suspend fun store(key: String, value: TangemPayCardFrozenState) { + dataStore.store(key, value) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayCardFrozenStateStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayCardFrozenStateStore.kt new file mode 100644 index 0000000000..61f4f1b0c0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayCardFrozenStateStore.kt @@ -0,0 +1,13 @@ +package com.tangem.datasource.local.visa + +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import kotlinx.coroutines.flow.Flow + +interface TangemPayCardFrozenStateStore { + + suspend fun getSyncOrNull(key: String): TangemPayCardFrozenState? + + fun get(key: String): Flow + + suspend fun store(key: String, value: TangemPayCardFrozenState) +} \ No newline at end of file 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..63f65c3ff3 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,12 +1,16 @@ package com.tangem.datasource.local.visa -import com.tangem.domain.visa.model.VisaAuthTokens +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.model.TangemPayAuthTokens interface TangemPayStorage { - suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) + suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) + suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? - suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? + suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) + + suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? suspend fun storeOrderId(customerWalletAddress: String, orderId: String) @@ -14,5 +18,11 @@ 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 storeCheckCustomerWalletResult(userWalletId: UserWalletId) + suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): 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..d92d9ff26e 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,10 @@ class ApiConfigTest { ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk()) ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk()) ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk()) + ApiConfig.ID.MoonPay -> MoonPay() + ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) + ApiConfig.ID.News -> News(authProvider = appAuthProvider) + ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk()) } } } 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..37f48a1f83 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 @@ -2,7 +2,6 @@ package com.tangem.datasource.api.common.config.managers import android.os.Build import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.config.* @@ -14,7 +13,9 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider +import com.tangem.test.core.ProvideTestModels import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider import com.tangem.utils.version.AppVersionProvider @@ -39,6 +40,7 @@ internal class ProdApiConfigsManagerTest { private val appVersionProvider = mockk() private val expressAuthProvider = mockk() private val stakeKitAuthProvider = mockk() + private val p2pEthPoolAuthProvider = mockk() private val appAuthProvider = mockk() private val appInfoProvider = mockk() private val tangemApiKeyProvider = mockk>() @@ -58,6 +60,7 @@ internal class ProdApiConfigsManagerTest { every { appVersionProvider.versionName } returns VERSION_NAME every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY + every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY every { appAuthProvider.getApiKey(any()) } returns tangemApiKeyProvider coEvery { tangemApiKeyProvider.invoke() } returns TANGEM_API_KEY coEvery { appAuthProvider.getCardId() } returns APP_CARD_ID @@ -109,6 +112,10 @@ 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() + ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) + ApiConfig.ID.News -> News(authProvider = appAuthProvider) + ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider) } } } @@ -121,6 +128,10 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.StakeKit -> createStakeKitModel() ApiConfig.ID.TangemPay -> createTangemPayModel() ApiConfig.ID.BlockAid -> createBlockAidSdkModel() + ApiConfig.ID.MoonPay -> createMoonPayModel() + ApiConfig.ID.P2PEthPool -> createP2PModel() + ApiConfig.ID.News -> createNewsModel() + ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel() } } @@ -239,7 +250,21 @@ internal class ProdApiConfigsManagerTest { id = ApiConfig.ID.TangemPay, expected = ApiEnvironmentConfig( environment = ApiEnvironment.DEV, - baseUrl = "[REDACTED_ENV_URL]", + baseUrl = "https://api.dev.us.paera.com/bff/", + headers = mapOf( + "version" to ProviderSuspend { VERSION_NAME }, + "platform" to ProviderSuspend { "Android" }, + ), + ), + ) + } + + private fun createTangemPayAuthModel(): TestModel { + return TestModel( + id = ApiConfig.ID.TangemPayAuth, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "https://api.dev.us.paera.com/", headers = mapOf( "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, @@ -263,6 +288,54 @@ 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 createP2PModel(): TestModel { + return TestModel( + id = ApiConfig.ID.P2PEthPool, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.p2p.org/", + headers = mapOf( + "Authorization" to ProviderSuspend { "Bearer $P2P_API_KEY" }, + "accept" to ProviderSuspend { "application/json" }, + "Content-Type" to ProviderSuspend { "application/json" }, + ), + ), + ) + } + + private fun createNewsModel(): TestModel { + val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV to "[REDACTED_ENV_URL]" + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD to "https://tangem.com/" + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + return TestModel( + id = ApiConfig.ID.News, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = baseUrl, + headers = mapOf( + "api-key" to ProviderSuspend { TANGEM_API_KEY }, + ), + ), + ) + } + private fun String.checkHeaderValueOrEmpty(): String { for (i in this.indices) { val c = this[i] @@ -281,6 +354,7 @@ internal class ProdApiConfigsManagerTest { const val VERSION_NAME = "debug" const val EXPRESS_SESSION_ID = "express_session_id" const val STAKE_KIT_API_KEY = "stake_kit_api_key" + const val P2P_API_KEY = "p2p_api_key" const val APP_CARD_ID = "app_card_id" const val APP_CARD_PUBLIC_KEY = "Bearer app_public_key" } diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/converters/WalletIdBodyConverterTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt similarity index 91% rename from data/wallets/src/test/java/com/tangem/data/wallets/converters/WalletIdBodyConverterTest.kt rename to core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt index af8f47a2a9..299deb3a22 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/converters/WalletIdBodyConverterTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt @@ -1,12 +1,13 @@ -package com.tangem.data.wallets.converters +package com.tangem.datasource.api.tangemTech.converters import com.google.common.truth.Truth.assertThat import com.tangem.datasource.api.tangemTech.models.CardInfoBody import com.tangem.datasource.api.tangemTech.models.WalletIdBody +import com.tangem.datasource.api.tangemTech.models.WalletType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.mockk -import org.junit.Test +import org.junit.jupiter.api.Test class WalletIdBodyConverterTest { @@ -36,6 +37,7 @@ class WalletIdBodyConverterTest { WalletIdBody( walletId = walletId.stringValue, name = walletName, + walletType = WalletType.COLD, cards = listOf( CardInfoBody( cardId = "card1", @@ -72,6 +74,7 @@ class WalletIdBodyConverterTest { assertThat(result).isEqualTo( WalletIdBody( walletId = walletId.stringValue, + walletType = WalletType.COLD, name = walletName, cards = emptyList(), ), diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/logs/LogsSanitizerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/logs/LogsSanitizerTest.kt index db62131d61..0507efde15 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/local/logs/LogsSanitizerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/logs/LogsSanitizerTest.kt @@ -1,9 +1,9 @@ package com.tangem.datasource.local.logs import com.google.common.truth.Truth +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource /** [REDACTED_AUTHOR] @@ -12,7 +12,7 @@ import org.junit.jupiter.params.provider.MethodSource internal class LogsSanitizerTest { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun sanitize(model: TestModel) { // Act val actual = LogsSanitizer.sanitize(model.input) diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DefaultRouter.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DefaultRouter.kt index ed5cb7be3f..20947a1913 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DefaultRouter.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DefaultRouter.kt @@ -1,9 +1,9 @@ package com.tangem.core.decompose.navigation -import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceCurrent import com.arkivanov.essenty.instancekeeper.InstanceKeeper import kotlin.reflect.KClass @@ -14,11 +14,14 @@ internal class DefaultRouter( private val navigation: StackNavigation get() = navigationProvider.getOrCreate() - @OptIn(ExperimentalDecomposeApi::class) override fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit) { navigation.pushNew(route, onComplete) } + override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) { + navigation.replaceCurrent(route, { onComplete(true) }) + } + override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) { val newRoutes = routes.toList() diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DummyRouter.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DummyRouter.kt index 77e7a8f59d..b08ad9b3b5 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DummyRouter.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/DummyRouter.kt @@ -8,6 +8,10 @@ class DummyRouter : Router { onComplete(true) } + override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) { + onComplete(true) + } + override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) { onComplete(true) } diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/Router.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/Router.kt index da7f859b8f..4fe5c05082 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/Router.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/Router.kt @@ -16,6 +16,14 @@ interface Router { */ fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit = {}) + /** + * Replaces a current route with a new one. + * + * @param route The route to replace a current one. + * @param onComplete The callback to be invoked when the operation is complete. + */ + fun replaceCurrent(route: Route, onComplete: (isSuccess: Boolean) -> Unit = {}) + /** * Replaces ***all*** routes in the navigation stack with the specified [routes]. * diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerPopRouter.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerPopRouter.kt index 12242d09b7..310910ddc0 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerPopRouter.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerPopRouter.kt @@ -18,6 +18,10 @@ inline fun AppComponentContext.InnerPopRouter( router.push(route, onComplete) } + override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) { + router.replaceCurrent(route, onComplete) + } + override fun replaceAll(vararg routes: Route, onComplete: (Boolean) -> Unit) { router.replaceAll(*routes, onComplete = onComplete) } diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerRouter.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerRouter.kt index 788afecd42..d187947ff5 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerRouter.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/navigation/inner/InnerRouter.kt @@ -3,6 +3,7 @@ package com.tangem.core.decompose.navigation.inner import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceCurrent import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route import com.tangem.core.decompose.navigation.Router @@ -32,6 +33,14 @@ inline fun AppComponentContext.InnerRouter( } } + override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) { + if (route is T) { + stackNavigation.replaceCurrent(route, { onComplete(true) }) + } else { + fallBackRouter.replaceCurrent(route, onComplete) + } + } + override fun replaceAll(vararg routes: Route, onComplete: (Boolean) -> Unit) { if (routes.any { it is T }) { val newRoutes = routes.toList().filterIsInstance() diff --git a/core/ui/detekt-baseline-debug.xml b/core/ui/detekt-baseline-debug.xml new file mode 100644 index 0000000000..a625c6029f --- /dev/null +++ b/core/ui/detekt-baseline-debug.xml @@ -0,0 +1,33 @@ + + + + + BooleanPropertyNaming:BigDecimalPercentFormat.kt$BigDecimalPercentFormat$val withPercentSign: Boolean = true + BooleanPropertyNaming:FullScreen.kt$FullScreenLayout$private val focusable: Boolean + BooleanPropertyNaming:TokenItemState.kt$TokenItemState.TitleState.Content$val earnApyIsActive: Boolean = false + CanBeNonNullable:FooterContainer.kt$footer: TextReference? = null + CanBeNonNullable:InputRowImageBase.kt$iconRes: Int? + CanBeNonNullable:InputRowImageInfo.kt$subtitleEndIconRes: Int? + CanBeNonNullable:SearchBar.kt$keyboardController: SoftwareKeyboardController? + CanBeNonNullable:TextFields.kt$caption: String? = null + CanBeNonNullable:TransactionList.kt$txHistoryItems: LazyPagingItems<TxHistoryState.TxHistoryItemState>? + CastNullableToNonNullableType:DialogFullScreen.kt$as + CastNullableToNonNullableType:FullScreen.kt$FullScreenLayout$as + MultilineLambdaItParameter:Actions.kt${ ActionButtonContent( config = config, text = { textColor -> Text(text = config.text, textColor = textColor) }, modifier = it.padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing24, ), ) } + MultilineLambdaItParameter:TangemDropdownMenu.kt${ if (it) { // Menu is expanded. 1f } else { // Menu is dismissed. 0.8f } } + MultilineLambdaItParameter:TangemDropdownMenu.kt${ if (it) { // Menu is expanded. 1f } else { // Menu is dismissed. 0f } } + NestedScopeFunctions:MessageBottomSheetUMV2.kt$apply(init) + NestedScopeFunctions:Shadow.kt$apply { isDither = true isAntiAlias = true setShadowLayer( radiusPx, offset.x.toPx(), offset.y.toPx(), color.toArgb(), ) } + NoNameShadowing:PinTextField.kt$value + NoNameShadowing:SimpleTextField.kt$textStyle + NoNameShadowing:TangemTheme.kt$systemUiController + NoNameShadowing:TextAnimatedCounter.kt$char + PropertyUsedBeforeDeclaration:InputManager.kt$InputManager$_query + ReusedModifierInstance:EllipsisText.kt$Text( text = layoutText, color = color, style = style, fontStyle = fontStyle, textDecoration = textDecoration, textAlign = textAlign, softWrap = softWrap, maxLines = 1, onTextLayout = { textLayoutResultState.value = it }, modifier = modifier, ) + ReusedModifierInstance:Label.kt$Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .padding(horizontal = 4.dp) .clip(TangemTheme.shapes.roundedCorners8) .background(color = backgroundColor) .then( if (state.onClick != null) { Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = ripple(), onClick = state.onClick, ) } else { Modifier }, ) .padding(horizontal = 8.dp, vertical = 4.dp), ) { Text( modifier = Modifier.weight(1.0f, fill = false), text = text.resolveReference(), style = TangemTheme.typography.caption1, color = textColor, ) AnimatedVisibility(state.icon != null) { val wrappedIcon = remember(this) { requireNotNull(state.icon) } Icon( imageVector = ImageVector.vectorResource(wrappedIcon), tint = iconColor, contentDescription = null, modifier = Modifier .size(16.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = false), onClick = { state.onIconClick?.invoke() }, ), ) } } + ReusedModifierInstance:TangemRadioButton.kt$AnimatedVisibility( visible = isSelected, label = "Radio button animation", modifier = modifier .size(TangemTheme.dimens.size24), ) { Icon( painter = painterResource(id = R.drawable.ic_check_circle_24), contentDescription = null, tint = TangemTheme.colors.control.checked, ) } + ReusedModifierInstance:TokenPrice.kt$Icon( modifier = modifier, painter = painterResource( id = when (animatedType) { PriceChangeType.UP -> R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 }, ), tint = when (animatedType) { PriceChangeType.UP -> TangemTheme.colors.icon.accent PriceChangeType.DOWN -> TangemTheme.colors.icon.warning PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive }, contentDescription = null, ) + UnnecessaryEventHandlerParameter:PinTextField.kt$onValueChange: (String) -> Unit + UnnecessaryEventHandlerParameter:ResizableText.kt$onFontSizeChange: (Float) -> Unit + + diff --git a/core/ui/src/main/java/com/tangem/core/ui/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/containers/FooterContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/components/containers/FooterContainer.kt index 4c44d3992a..d711a1df80 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/containers/FooterContainer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/containers/FooterContainer.kt @@ -8,10 +8,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.FooterTestTags /** * Container for footer info below the text field @@ -37,7 +39,8 @@ fun FooterContainer( style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier - .padding(paddingValues), + .padding(paddingValues) + .testTag(FooterTestTags.FOOTER_TEXT), ) } } 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/InputRowImageBase.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt index 36c14965b1..322fb14ef4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.components.inputrow import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon @@ -15,6 +16,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R @@ -35,7 +37,7 @@ import com.tangem.core.ui.res.TangemThemePreview * @param modifier modifier * @param caption caption text * @param imageUrl icon to load - * @param iconRes icon resource + * @param iconResVector icon resource * @param subtitleColor subtitle text color * @param captionColor caption text color * @param iconTint icon tint @@ -47,12 +49,13 @@ import com.tangem.core.ui.res.TangemThemePreview */ @Suppress("LongMethod") @Composable -internal fun InputRowImageBase( +fun InputRowImageBase( subtitle: TextReference, modifier: Modifier = Modifier, caption: TextReference? = null, imageUrl: String? = null, - @DrawableRes iconRes: Int? = null, + @DrawableRes iconResVector: Int? = null, + @DrawableRes iconResWebp: Int? = null, subtitleColor: Color = TangemTheme.colors.text.primary1, captionColor: Color = TangemTheme.colors.text.tertiary, iconTint: Color = TangemTheme.colors.icon.informative, @@ -76,7 +79,7 @@ internal fun InputRowImageBase( .clip(TangemTheme.shapes.roundedCornersXLarge), ) SpacerW12() - } else if (iconRes != null) { + } else if (iconResVector != null) { Box( contentAlignment = Alignment.Center, modifier = Modifier @@ -85,13 +88,20 @@ internal fun InputRowImageBase( .background(iconTint.copy(alpha = 0.08f)), ) { Icon( - painter = rememberVectorPainter(image = ImageVector.vectorResource(id = iconRes)), + painter = rememberVectorPainter(image = ImageVector.vectorResource(id = iconResVector)), tint = iconTint, contentDescription = null, modifier = Modifier.size(TangemTheme.dimens.size18), ) } SpacerW12() + } else if (iconResWebp != null) { + Image( + painter = painterResource(iconResWebp), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size36), + ) + SpacerW12() } Column { Row { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt index b5fbc11286..6daada836b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt @@ -83,7 +83,7 @@ fun InputRowImageInfo( subtitle = subtitle, caption = caption, imageUrl = imageUrl, - iconRes = iconRes, + iconResVector = iconRes, iconTint = iconTint, subtitleColor = subtitleColor, captionColor = captionColor, 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..4a4334393e 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 @@ -134,13 +134,16 @@ fun InputRowRecipient( QrButton( visible = value.isBlank(), onQrCodeClick = onQrCodeClick, + modifier = Modifier.testTag(SendAddressScreenTestTags.QR_BUTTON), ) PasteButton( isPasteButtonVisible = value.isBlank(), 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 +227,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/NetworkTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/NetworkTitle.kt index 590e93ddb5..e7644ee705 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/NetworkTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/NetworkTitle.kt @@ -71,7 +71,6 @@ fun NetworkTitle( Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing8)) Box( modifier = Modifier - .weight(weight = 1f) .heightIn(min = TangemTheme.dimens.size24), contentAlignment = Alignment.CenterEnd, content = action, 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/text/TooltipText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt index 801d47263b..42bf28e249 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt @@ -82,7 +82,7 @@ private fun Preview_TooltipText() { ) { TooltipText( text = stringReference("Text"), - onInfoClick = { /* [REDACTED_TODO_COMMENT]*/ }, + onInfoClick = { }, ) } } 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 1f8a4ea2b6..7f05cdb7cb 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 @@ -240,6 +240,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/components/tokenlist/TokenListItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt index fa775ca36c..b5539edab3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt @@ -83,7 +83,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B } @Composable -private fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) { +fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index 329a2d7744..aa6fab81a5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -98,6 +98,7 @@ fun getActiveIconRes(blockchainId: String): Int { "quai", "quai/test" -> R.drawable.img_quai_22 "linea", "linea/test" -> R.drawable.img_linea_22 "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 + "plasma", "plasma/test" -> R.drawable.img_plasma_22 else -> R.drawable.ic_alert_24 } } @@ -194,6 +195,7 @@ fun getActiveIconResByCoinId(coinId: String): Int { "quai", "quai/test" -> R.drawable.img_quai_22 "linea", "linea/test" -> R.drawable.img_linea_22 "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 + "plasma", "plasma/test" -> R.drawable.img_plasma_22 else -> R.drawable.ic_alert_24 } } @@ -293,6 +295,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "quai", "quai/test" -> R.drawable.ic_quai_22 "linea", "linea/test" -> R.drawable.ic_linea_22 "arbitrum-nova" -> R.drawable.ic_arbitrum_nova_22 + "plasma", "plasma/test" -> R.drawable.ic_plasma_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file 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..85535f739b 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 @@ -66,7 +66,7 @@ fun TangemTheme( ) { val themeColors = if (isDark) darkThemeColors() else lightThemeColors() val rememberedColors = remember { themeColors } - .also { it.update(themeColors) } + .apply { update(themeColors) } val systemUiController = rememberSystemUiController() val systemBarsIconsController = remember(systemUiController) { SystemBarsIconsController(systemUiController) } @@ -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..476a412a20 --- /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 } + .apply { 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/FooterTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/FooterTestTags.kt new file mode 100644 index 0000000000..be38de2351 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/FooterTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object FooterTestTags { + const val FOOTER_TEXT = "FOOTER_TEXT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/QrCodeScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/QrCodeScreenTestTags.kt new file mode 100644 index 0000000000..a50445ab92 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/QrCodeScreenTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object QrCodeScreenTestTags { + const val FLASHLIGHT_BUTTON = "QR_CODE_SCREEN_FLASHLIGHT_BUTTON" + const val GALLERY_BUTTON = "QR_CODE_SCREEN_GALLERY_BUTTON" +} \ 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..662611b4d4 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,22 @@ 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 CONTAINER = "SEND_ADDRESS_SCREEN_CONTAINER" + + const val ADDRESS_TEXT_FIELD_TITLE = "SEND_ADDRESS_SCREEN_TEXT_FIELD_TITLE" + const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_SCREEN_TEXT_FIELD" + const val QR_BUTTON = "SEND_ADDRESS_SCREEN_QR_BUTTON" + const val ADDRESS_PASTE_BUTTON = "SEND_ADDRESS_SCREEN_ADDRESS_PASTE_BUTTON" + const val RESOLVED_ADDRESS = "SEND_ADDRESS_SCREEN_RESOLVED_ADDRESS" + + const val RECENT_ADDRESS_ITEM = "SEND_ADDRESS_SCREEN_RECENT_ADDRESS_ITEM" + 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 RECENT_ADDRESS_TRANSACTION_ICON = "SEND_ADDRESS_SCREEN_RECENT_ADDRESS_TRANSACTION_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_credit_card_add_22.xml b/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml deleted file mode 100644 index 560684e1a9..0000000000 --- a/core/ui/src/main/res/drawable/ic_credit_card_add_22.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/core/ui/src/main/res/drawable/ic_credit_card_add_24.xml b/core/ui/src/main/res/drawable/ic_credit_card_add_24.xml new file mode 100644 index 0000000000..5377128e64 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_credit_card_add_24.xml @@ -0,0 +1,9 @@ + + + + + + + + + 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/drawable/ic_plasma_22.xml b/core/ui/src/main/res/drawable/ic_plasma_22.xml new file mode 100644 index 0000000000..f86a31a6b7 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_plasma_22.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_security_check_22.xml b/core/ui/src/main/res/drawable/ic_security_check_22.xml deleted file mode 100644 index fd8956d8fa..0000000000 --- a/core/ui/src/main/res/drawable/ic_security_check_22.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml b/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml deleted file mode 100644 index c6cb19e2e7..0000000000 --- a/core/ui/src/main/res/drawable/ic_shopping_basket_22.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_snow_24.xml b/core/ui/src/main/res/drawable/ic_snow_24.xml new file mode 100644 index 0000000000..7561e7996b --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_snow_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_stars_20.xml b/core/ui/src/main/res/drawable/ic_stars_20.xml new file mode 100644 index 0000000000..fef8ac03d9 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_stars_20.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_start_circle_12.xml b/core/ui/src/main/res/drawable/ic_start_circle_12.xml new file mode 100644 index 0000000000..7846697a80 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_start_circle_12.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/img_plasma_22.xml b/core/ui/src/main/res/drawable/img_plasma_22.xml new file mode 100644 index 0000000000..6669b0963f --- /dev/null +++ b/core/ui/src/main/res/drawable/img_plasma_22.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_tangem_pay_visa.webp b/core/ui/src/main/res/drawable/img_tangem_pay_visa.webp new file mode 100644 index 0000000000..2a808bbb73 Binary files /dev/null and b/core/ui/src/main/res/drawable/img_tangem_pay_visa.webp differ diff --git a/core/ui/src/main/res/drawable/img_tangem_pay_visa_frozen.webp b/core/ui/src/main/res/drawable/img_tangem_pay_visa_frozen.webp new file mode 100644 index 0000000000..bf2b79a46f Binary files /dev/null and b/core/ui/src/main/res/drawable/img_tangem_pay_visa_frozen.webp differ diff --git a/core/ui/src/main/res/drawable/img_visa_36.webp b/core/ui/src/main/res/drawable/img_visa_36.webp new file mode 100644 index 0000000000..dddc185740 Binary files /dev/null and b/core/ui/src/main/res/drawable/img_visa_36.webp differ 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/build.gradle.kts b/core/utils/build.gradle.kts index 4a731b1f09..48e632eff4 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(deps.jodatime) // endregion + testImplementation(deps.test.coroutine) testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.truth) diff --git a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt index 98ba454b64..90655e7d52 100644 --- a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt +++ b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt @@ -14,6 +14,7 @@ object StringsSigns { const val NON_BREAKING_SPACE = '\u00A0' const val PERCENT = "%" const val THREE_STARS = "\u2217\u2217\u2217" + const val ASTERISK = "\u2217" const val PASSWORD_VISUAL_CHAR = '\u2022' const val APPROXIMATE = "≈" const val WHITE_SPACE = " " 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/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt index b57c8e0015..8a2fd97b72 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineExt.kt @@ -19,6 +19,16 @@ suspend fun waitForDelay(delay: Long, block: suspend CoroutineScope.() -> R) actionInvoke.await() } +suspend inline fun T.runSuspendCatching(block: suspend T.() -> R): Result { + return try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } +} + class Debouncer { private var debounceJob: Job? = null diff --git a/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt b/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt new file mode 100644 index 0000000000..bada167774 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt @@ -0,0 +1,67 @@ +package com.tangem.utils.retryer + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlin.math.pow +import kotlin.random.Random + +/** + * Utility class to retry a suspending block of code a specified number of times with exponential backoff and jitter. + * + * @param attempt The number of attempts to retry the block. + * @param block The suspending block of code to be executed. It should return true if successful, false otherwise. + * +[REDACTED_AUTHOR] + */ +class Retryer( + private val attempt: Int, + private val block: suspend (Int) -> Boolean, +) { + + init { + require(attempt > 0) { "Retryer.attempt should be greater than 0" } + } + + /** + * Launches the retry mechanism, executing the block up to the specified number of attempts. + * If the block returns true, the retrying stops. + * + * @throws CancellationException if the coroutine is cancelled during execution. + * @throws Error if the block throws an Error. + */ + suspend fun launch() { + repeat(attempt) { iteration -> + val timeInMillis = calculateDelay(iteration = iteration) + delay(timeInMillis) + + val result = try { + block(iteration) + } catch (e: CancellationException) { + throw e + } catch (e: Error) { + throw e + } catch (_: Exception) { + false + } + + if (result) return + } + } + + /** + * Calculates the delay before the next retry attempt using exponential backoff with jitter. + * delay = BASE * 2^iteration +- JITTER + * + * @param iteration The current iteration number (0-based). + * @return The calculated delay in milliseconds. + */ + private fun calculateDelay(iteration: Int): Long { + return (BASE_DELAY * 2.0.pow(iteration.toDouble()) + Random.nextLong(-JITTER, JITTER)).toLong() + } + + private companion object { + + const val BASE_DELAY = 1000L // 1 second + const val JITTER = 500L // 0.5 second + } +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/retryer/RetryerPool.kt b/core/utils/src/main/java/com/tangem/utils/retryer/RetryerPool.kt new file mode 100644 index 0000000000..6aeb28dbd4 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/retryer/RetryerPool.kt @@ -0,0 +1,36 @@ +package com.tangem.utils.retryer + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +/** + * Utility class to manage and launch multiple [Retryer] instances one by one. + * + * @param coroutineScope The [CoroutineScope] in which the retryers will be launched. + * +[REDACTED_AUTHOR] + */ +class RetryerPool(private val coroutineScope: CoroutineScope) { + + private val queue = Channel(Channel.UNLIMITED) + + init { + coroutineScope.launch { + for (retryer in queue) { + retryer.launch() + } + } + } + + /** + * Adds a [Retryer] to the pool and launches it one by one. + * + * @param retryer The [Retryer] instance to be added and launched. + * @return The current [RetryerPool] instance for chaining. + */ + operator fun plus(retryer: Retryer): RetryerPool { + queue.trySend(retryer) + return this + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerPoolTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerPoolTest.kt new file mode 100644 index 0000000000..c752032279 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerPoolTest.kt @@ -0,0 +1,53 @@ +package com.tangem.utils.retryer + +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RetryerPoolTest { + + private val coroutineScope = CoroutineScope(SupervisorJob() + TestingCoroutineDispatcherProvider().default) + + @Test + fun `retryer pool plus operator adds retryer to the pool`() { + // Arrange + val retryerPool = RetryerPool(coroutineScope = coroutineScope) + val retryer = mockk(relaxUnitFun = true) + + // Act + retryerPool + retryer + + // Assert + coVerify(exactly = 1) { + retryer.launch() + } + } + + @Test + fun `multiple retryers added to the pool are launched`() { + // Arrange + val retryerPool = RetryerPool(coroutineScope = coroutineScope) + val retryer1 = mockk(relaxUnitFun = true) + val retryer2 = mockk(relaxUnitFun = true) + val retryer3 = mockk(relaxUnitFun = true) + + // Act + retryerPool + retryer1 + retryer2 + retryer3 + + // Assert + coVerifyOrder { + retryer1.launch() + retryer2.launch() + retryer3.launch() + } + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt new file mode 100644 index 0000000000..d4baffeb7c --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt @@ -0,0 +1,136 @@ +package com.tangem.utils.retryer + +import com.google.common.truth.Truth +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RetryerTest { + + @Test + fun `retryer with zero attempts should throw exception`() { + // Act + val actual = runCatching { + Retryer(attempt = 0) { false } + } + .exceptionOrNull() + + // Assert + val expected = IllegalArgumentException("Retryer.attempt should be greater than 0") + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `retryer with negative attempts should throw exception`() { + // Act + val actual = runCatching { + Retryer(attempt = -5) { false } + } + .exceptionOrNull() + + // Assert + val expected = IllegalArgumentException("Retryer.attempt should be greater than 0") + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `retryer with positive attempts should be created successfully`() { + // Act + val actual = runCatching { + Retryer(attempt = 3) { false } + } + .getOrNull() + + // Assert + Truth.assertThat(actual).isNotNull() + } + + @Test + fun `retryer should be repeated until block returns true`() = runTest { + // Arrange + val attempt = 5 + val block = mockk Boolean>() + val retryer = Retryer(attempt = attempt, block = block) + + coEvery { block(any()) } returnsMany List(attempt) { it == attempt - 2 } // returns true on the 4th call + + // Act + retryer.launch() + + // Assert + coVerifyOrder { + block.invoke(0) + block.invoke(1) + block.invoke(2) + block.invoke(3) + } + } + + @Test + fun `retryer should be called once`() = runTest { + // Arrange + val attempt = 5 + val block = mockk Boolean>() + val retryer = Retryer(attempt = attempt, block = block) + + coEvery { block(1) } returns true // for the first attempt + + // Act + retryer.launch() + + // Assert + coVerifyOrder { + block.invoke(1) + } + } + + @Test + fun `retryer should throw after all attempts failed`() = runTest { + // Arrange + val attempt = 3 + val block = mockk Boolean>() + val retryer = Retryer(attempt = attempt, block = block) + + coEvery { block(any()) } returnsMany List(attempt) { false } + + // Act + retryer.launch() + + // Assert + coVerifyOrder { + block.invoke(0) + block.invoke(1) + block.invoke(2) + } + } + + @Test + fun `retryer should handle exceptions in block and continue retrying`() = runTest { + // Arrange + val attempt = 4 + val block = mockk Boolean>() + val retryer = Retryer(attempt = attempt, block = block) + + coEvery { block(any()) } throws IllegalStateException("Test Exception") andThenMany List(attempt - 1) { false } + + // Act + retryer.launch() + + // Assert + coVerifyOrder { + block.invoke(0) + block.invoke(1) + block.invoke(2) + block.invoke(3) + } + } +} \ No newline at end of file diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index a818b24ccb..7e38a27c0c 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 @@ -61,11 +67,8 @@ dependencies { // endregion // region Test - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) - testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(projects.common.test) + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) // endregion } \ No newline at end of file diff --git a/data/account/detekt-baseline-debug.xml b/data/account/detekt-baseline-debug.xml new file mode 100644 index 0000000000..f0b34d22a4 --- /dev/null +++ b/data/account/detekt-baseline-debug.xml @@ -0,0 +1,13 @@ + + + + + CanBeNonNullable:FetchWalletAccountsErrorHandler.kt$FetchWalletAccountsErrorHandler$savedAccountsResponse: GetWalletAccountsResponse? + MultilineLambdaItParameter:DefaultAccountsCRUDRepository.kt$DefaultAccountsCRUDRepository${ if (it is HttpException && it.code == HttpException.Code.NOT_MODIFIED) { null } else { throw it } } + MultilineLambdaItParameter:GetWalletAccountsResponseExt.kt${ enrichedTokensByAccountId[it].orEmpty().map { token -> // Tokens from unexisting accounts should be copied to the main account token.copy(accountId = accountDTO.id) } } + NoNameShadowing:GetWalletAccountsResponseExt.kt$tokens + NullableToStringCall:AccountListCryptoCurrenciesProducer.kt$AccountListCryptoCurrenciesProducer$${this::class.simpleName} + NullableToStringCall:DefaultMultiWalletCryptoCurrenciesProducer.kt$DefaultMultiWalletCryptoCurrenciesProducer$${this::class.simpleName} + UnnecessaryLet:DefaultAccountsCRUDRepository.kt$DefaultAccountsCRUDRepository$let(AccountName::invoke) + + 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..2da5ac22bc 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 @@ -3,6 +3,8 @@ package com.tangem.data.account.converter import arrow.core.getOrElse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.wallet.UserWallet import com.tangem.utils.converter.Converter import dagger.assisted.Assisted @@ -27,12 +29,15 @@ internal class AccountListConverter @AssistedInject constructor( } override fun convert(value: GetWalletAccountsResponse): AccountList { + val sortType = value.wallet.sort?.let(TokensSortTypeConverter::convert) ?: TokensSortType.NONE + val groupType = value.wallet.group?.let(TokensGroupTypeConverter::convert) ?: TokensGroupType.NONE + 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), + sortType = sortType, + groupType = groupType, ) .getOrElse { error("Failed to convert GetWalletAccountsResponse to AccountList: $it") 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..5dc4c61679 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,10 @@ 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.accounts.AccountTokenMigrationStore +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 +23,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 +45,8 @@ internal object AccountDataModule { accountsResponseStoreFactory: AccountsResponseStoreFactory, userWalletsStore: UserWalletsStore, userTokensSaver: UserTokensSaver, - eTagsStore: ETagsStore, accountConverterFactoryContainer: AccountConverterFactoryContainer, + @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, ): AccountsCRUDRepository { return DefaultAccountsCRUDRepository( @@ -53,8 +56,9 @@ internal object AccountDataModule { archivedAccountsStoreFactory = ArchivedAccountsStoreFactory, userWalletsStore = userWalletsStore, userTokensSaver = userTokensSaver, - eTagsStore = eTagsStore, + archivedAccountsETagStore = RuntimeStateStore(emptyMap()), convertersContainer = accountConverterFactoryContainer, + resources = context.resources, dispatchers = dispatchers, ) } @@ -72,9 +76,11 @@ internal object AccountDataModule { fun provideMainAccountTokensMigration( accountsResponseStoreFactory: AccountsResponseStoreFactory, userTokensSaver: UserTokensSaver, + accountTokenMigrationStore: AccountTokenMigrationStore, ): MainAccountTokensMigration { return DefaultMainAccountTokensMigration( accountsResponseStoreFactory = accountsResponseStoreFactory, + accountTokenMigrationStore = accountTokenMigrationStore, userTokensSaver = userTokensSaver, ) } 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..99d4ac7ff6 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,8 @@ 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.api.tangemTech.models.orDefault import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -51,16 +52,30 @@ 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 + val fetchResult = fetchWalletAccounts(userWalletId, savedAccountsResponse) + val accountsResponse = fetchResult.accountsResponse - if (accountsResponse.accounts.isEmpty()) { - initializeAccounts(userWalletId, accountsResponse) - } else if (accountsResponse.unassignedTokens.isNotEmpty()) { - assignTokens(userWalletId, accountsResponse) + val updatedResponse = when { + accountsResponse.accounts.isEmpty() -> { + initializeAccounts(userWalletId, accountsResponse) + } + accountsResponse.unassignedTokens.isNotEmpty() -> { + assignTokens(userWalletId, accountsResponse) + } + else -> accountsResponse } + + if (fetchResult.error != null) { + throw fetchResult.error + } + + return updatedResponse + } + + override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? { + return getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() } override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { @@ -115,7 +130,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private suspend fun fetchWalletAccounts( userWalletId: UserWalletId, savedAccountsResponse: GetWalletAccountsResponse?, - ): GetWalletAccountsResponse? { + ): FetchResult { return safeApiCall( call = { val apiResponse = withContext(dispatchers.io) { @@ -130,7 +145,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( val responseBody = apiResponse.bind() store(userWalletId = userWalletId, response = responseBody) - responseBody + FetchResult(responseBody) }, onError = { throwable -> // pushWalletAccounts and storeWalletAccounts help to avoid cyclic dependency @@ -145,33 +160,47 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( ) } - private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { + private suspend fun initializeAccounts( + userWalletId: UserWalletId, + accountsResponse: GetWalletAccountsResponse, + ): GetWalletAccountsResponse { + val hasSavedTokens = accountsResponse.unassignedTokens.isNotEmpty() + val response = defaultWalletAccountsResponseFactory.create( userWalletId = userWalletId, - userTokensResponse = UserTokensResponse( - group = accountsResponse.wallet.group, - sort = accountsResponse.wallet.sort, - tokens = accountsResponse.unassignedTokens, - ), + userTokensResponse = if (hasSavedTokens) { + UserTokensResponse( + group = accountsResponse.wallet.group.orDefault(), + sort = accountsResponse.wallet.sort.orDefault(), + tokens = accountsResponse.unassignedTokens, + ) + } else { + null + }, ) - userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) - val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) + store(userWalletId = userWalletId, response = response) - if (syncedResponse != null) { - store(userWalletId = userWalletId, response = syncedResponse) - } + push(userWalletId = userWalletId, accounts = response.accounts) + userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) + + return response } - 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) - userTokensSaver.push( + userTokensSaver.pushWithRetryer( userWalletId = userWalletId, response = accountsResponseWithTokens.toUserTokensResponse(), ) + + return accountsResponseWithTokens } private suspend fun getETag(userWalletId: UserWalletId): String? { @@ -189,4 +218,9 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore { return accountsResponseStoreFactory.create(userWalletId = userWalletId) } + + data class FetchResult( + val accountsResponse: GetWalletAccountsResponse, + val error: Throwable? = null, + ) } \ No newline at end of file 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..9e86b4848b 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,54 @@ package com.tangem.data.account.fetcher +import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult 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.converters.WalletIdBodyConverter 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.datasource.local.userwallet.UserWalletsStore 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] */ +@Suppress("LongParameterList") +@Singleton internal class FetchWalletAccountsErrorHandler @Inject constructor( + private val tangemTechApi: TangemTechApi, + private val userWalletsStore: UserWalletsStore, private val userTokensSaver: UserTokensSaver, private val userTokensResponseStore: UserTokensResponseStore, private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, + private val eTagsStore: ETagsStore, + private val dispatchers: CoroutineDispatcherProvider, ) { /** @@ -49,29 +68,35 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( savedAccountsResponse: GetWalletAccountsResponse?, pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse?, storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit, - ): GetWalletAccountsResponse? { + ): FetchResult { 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 + val response = requireNotNull(savedAccountsResponse) { + "Saved accounts response is null for wallet: $userWalletId" + } + + return FetchResult(response) } - 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.pushWithRetryer(userWalletId, userTokensResponse) } } storeWalletAccounts(userWalletId, response) - return response + return FetchResult(response, error) } private suspend fun createDefaultResponse(userWalletId: UserWalletId): GetWalletAccountsResponse { @@ -83,11 +108,33 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? { return userTokensResponseStore.getSyncOrNull(userWalletId) - ?.let { - it.copy( - tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = it.tokens), + ?.let { response -> + response.copy( + tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = response.tokens), ) } .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 userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null + + val creationResponse = withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = WalletIdBodyConverter.convert(userWallet), + ) + } + + 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..2bb36f1808 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) { @@ -113,9 +151,9 @@ internal class DefaultAccountsCRUDRepository( ) val syncedResponse = walletAccountsSaver.push(userWalletId = accountList.userWalletId, accounts = accountDTOs) - if (syncedResponse != null) { - walletAccountsSaver.store(userWalletId = accountList.userWalletId, response = syncedResponse) - } + ?: error("Failed to push accounts for wallet: ${accountList.userWalletId}") + + walletAccountsSaver.store(userWalletId = accountList.userWalletId, response = syncedResponse) } override suspend fun saveAccount(account: Account.CryptoPortfolio) { @@ -143,7 +181,7 @@ internal class DefaultAccountsCRUDRepository( return } - userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) + userTokensSaver.pushWithRetryer(userWalletId = userWalletId, response = response.toUserTokensResponse()) } override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option = option { @@ -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..d1a0449ebc 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,12 @@ 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.local.accounts.AccountTokenMigrationStore import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.domain.models.account.DerivationIndex @@ -32,6 +33,7 @@ import timber.log.Timber */ internal class DefaultMainAccountTokensMigration( private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val accountTokenMigrationStore: AccountTokenMigrationStore, private val userTokensSaver: UserTokensSaver, ) : MainAccountTokensMigration { @@ -78,12 +80,19 @@ internal class DefaultMainAccountTokensMigration( }, ) - userTokensSaver.push( + store.updateData { updatedResponse } + + val mainAccountName = mainAccount.name + val selectedAccountName = selectedAccount.name + if (mainAccountName != null && selectedAccountName != null) { + accountTokenMigrationStore.store(userWalletId, mainAccountName to selectedAccountName) + } + + val userTokensResponse = updatedResponse.toUserTokensResponse() + userTokensSaver.pushWithRetryer( userWalletId = userWalletId, - response = updatedResponse.toUserTokensResponse(), + response = userTokensResponse, onFailSend = { - // TODO: save failed state to retry later - // [REDACTED_JIRA] val exception = IllegalStateException("Failed to push updated tokens after migration") Timber.e(exception) raise(exception) 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..5dd158b035 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] * @@ -46,13 +32,30 @@ internal fun List.assignTokens( userWalletId: UserWalletId, tokens: List, ): List { - val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) + val enrichedTokensByAccountId = UserTokensResponseAccountIdEnricher(userWalletId, tokens) .groupBy { it.accountId } return map { accountDTO -> - accountDTO.copy( - tokens = enrichedTokens[accountDTO.id].orEmpty(), - ) + val accountTokens = enrichedTokensByAccountId[accountDTO.id].orEmpty() + val isMainAccount = accountDTO.derivationIndex == 0 + + val tokens = if (isMainAccount) { + val existingAccountIds = map(WalletAccountDTO::id).toSet() + val unexistingAccountIds = enrichedTokensByAccountId.keys - existingAccountIds + + val customTokens = unexistingAccountIds.flatMap { + enrichedTokensByAccountId[it].orEmpty().map { token -> + // Tokens from unexisting accounts should be copied to the main account + token.copy(accountId = accountDTO.id) + } + } + + accountTokens + customTokens + } else { + accountTokens + } + + accountDTO.copy(tokens = accountDTO.tokens.orEmpty() + tokens) } } @@ -63,5 +66,5 @@ internal fun WalletAccountDTO.assignTokens( val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) .filter { it.accountId == this.id } - return copy(tokens = enrichedTokens) + return copy(tokens = this.tokens.orEmpty() + enrichedTokens) } \ No newline at end of file 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/AccountListConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt index 1a7ae063f0..e192321af6 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt @@ -1,7 +1,6 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.models.AccountList @@ -10,6 +9,7 @@ import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.ProvideTestModels import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt index 0c22242e2a..533e4e12b0 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountNameConverterTest.kt @@ -1,8 +1,8 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.domain.models.account.AccountName +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt index bf08d5b7d9..4e6307ec70 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt @@ -1,7 +1,6 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.models.account.AccountId @@ -9,6 +8,7 @@ import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt index 7e3e41a243..3fb2ff2efd 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt @@ -1,7 +1,6 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.common.currency.UserTokensResponseFactory import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO @@ -10,6 +9,7 @@ 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.test.core.ProvideTestModels import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt index c6563f9fc8..7e8b0bf18e 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt @@ -1,9 +1,9 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.account.converter.CryptoPortfolioIconConverter.DataModel import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt index 2b09ada07f..d67698e528 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt @@ -1,7 +1,6 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.models.AccountList @@ -10,6 +9,7 @@ import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.ProvideTestModels import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk 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/converter/TokensGroupTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt index 62ba8b6f30..7784be0398 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt @@ -1,9 +1,9 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.models.TokensGroupType +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt index e5476f5c49..063f40733a 100644 --- a/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt @@ -1,9 +1,9 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.models.TokensSortType +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcherTest.kt index 0ded63586a..a2971c7891 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultMultiAccountListFetcherTest.kt @@ -2,13 +2,13 @@ package com.tangem.data.account.fetcher import arrow.core.left import arrow.core.right -import com.tangem.common.test.utils.assertEitherLeft -import com.tangem.common.test.utils.assertEitherRight import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.fetcher.MultiAccountListFetcher import com.tangem.domain.account.fetcher.SingleAccountListFetcher import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertEitherLeft +import com.tangem.test.core.assertEitherRight import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach 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..d9641de71a 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 @@ -1,10 +1,10 @@ package com.tangem.data.account.fetcher -import com.tangem.common.test.utils.assertEitherLeft -import com.tangem.common.test.utils.assertEitherRight import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.domain.account.fetcher.SingleAccountListFetcher import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertEitherLeft +import com.tangem.test.core.assertEitherRight import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach @@ -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..1bfbb38da1 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 @@ -3,6 +3,7 @@ package com.tangem.data.account.fetcher import com.google.common.truth.Truth import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO +import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory @@ -199,7 +200,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,11 +213,11 @@ class DefaultWalletAccountsFetcherTest { fetchWalletAccountsErrorHandler.handle( error = apiError.cause, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = savedAccountsResponse, pushWalletAccounts = any(), storeWalletAccounts = any(), ) - } returns savedAccountsResponse + } returns FetchResult(savedAccountsResponse) // Act fetcher.fetch(userWalletId) @@ -231,7 +232,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..3c8951fa2b 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,19 +3,25 @@ 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.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.WalletIdBody +import com.tangem.datasource.api.tangemTech.models.WalletType 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.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -27,14 +33,21 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class FetchWalletAccountsErrorHandlerTest { + private val tangemTechApi: TangemTechApi = mockk() + private val userWalletsStore: UserWalletsStore = 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, + userWalletsStore = userWalletsStore, userTokensSaver = userTokensSaver, userTokensResponseStore = userTokensResponseStore, defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, + eTagsStore = eTagsStore, + dispatchers = TestingCoroutineDispatcherProvider(), ) private val pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse = @@ -53,6 +66,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 +78,7 @@ class FetchWalletAccountsErrorHandlerTest { handler.handle( error = error, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = response, pushWalletAccounts = pushWalletAccounts, storeWalletAccounts = storeWalletAccounts, ) @@ -73,7 +88,7 @@ class FetchWalletAccountsErrorHandlerTest { userTokensResponseStore.getSyncOrNull(userWalletId = any()) defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any()) pushWalletAccounts(any(), any()) - userTokensSaver.push(userWalletId = any(), response = any()) + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any()) storeWalletAccounts(any(), any()) } } @@ -91,6 +106,29 @@ 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, + ) + + val walletName = "Wallet" + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.name } returns walletName + } + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + + coEvery { + tangemTechApi.createWallet( + WalletIdBody( + walletId = userWalletId.stringValue, + name = walletName, + walletType = WalletType.COLD, + ), + ) + } returns apiResponse coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse // Act @@ -104,7 +142,15 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { - userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + userTokensSaver.pushWithRetryer(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + tangemTechApi.createWallet( + WalletIdBody( + walletId = userWalletId.stringValue, + name = walletName, + walletType = WalletType.COLD, + ), + ) + eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue) pushWalletAccounts(userWalletId, listOf(accountDTO)) storeWalletAccounts(userWalletId, savedAccountsResponse) } @@ -166,7 +212,7 @@ class FetchWalletAccountsErrorHandlerTest { coVerify(inverse = true) { pushWalletAccounts(any(), any()) - userTokensSaver.push(userWalletId = any(), response = any()) + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any()) } } diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt index 05cb95cca1..f29c427e05 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt @@ -1,12 +1,12 @@ package com.tangem.data.account.producer import com.google.common.truth.Truth -import com.tangem.common.test.utils.getEmittedValues import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt index bbdc8679ef..983ba0706e 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt @@ -5,7 +5,6 @@ import com.tangem.blockchain.common.Blockchain 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.getEmittedValues import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore @@ -15,6 +14,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.* diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt index bc065b4578..a201c0551b 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -1,12 +1,12 @@ package com.tangem.data.account.producer import com.google.common.truth.Truth -import com.tangem.common.test.utils.getEmittedValues import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow diff --git a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt index 47c2e8f6fc..47b5fb3992 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt @@ -3,7 +3,6 @@ package com.tangem.data.account.producer import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.account.converter.AccountListConverter import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.store.AccountsResponseStore @@ -15,6 +14,7 @@ import com.tangem.domain.account.models.AccountList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.test.core.getEmittedValues import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest 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..68741d16a3 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,16 +1,15 @@ 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 -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.account.converter.* 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.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 @@ -27,6 +26,7 @@ import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow @@ -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/store/ArchivedAccountsStoreTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt index 70e6ddb2db..80fdaad362 100644 --- a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt @@ -1,7 +1,6 @@ package com.tangem.data.account.store import com.google.common.truth.Truth -import com.tangem.common.test.utils.getEmittedValues import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.models.account.AccountId @@ -9,6 +8,7 @@ import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.getEmittedValues import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Nested 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..fb8f294326 100644 --- a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt @@ -1,19 +1,20 @@ package com.tangem.data.account.token -import com.tangem.common.test.utils.assertEitherLeft -import com.tangem.common.test.utils.assertEitherRight import com.tangem.data.account.converter.createGetWalletAccountsResponse 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.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertEitherLeft +import com.tangem.test.core.assertEitherRight import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -29,10 +30,12 @@ class DefaultMainAccountTokensMigrationTest { private val accountsResponseStoreFactory = mockk() private val accountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) + private val accountTokenMigrationStore = mockk(relaxUnitFun = true) + private val userTokensSaver = mockk(relaxUnitFun = true) - private val userTokensSaver = mockk(relaxed = true) private val migration = DefaultMainAccountTokensMigration( accountsResponseStoreFactory = accountsResponseStoreFactory, + accountTokenMigrationStore = accountTokenMigrationStore, userTokensSaver = userTokensSaver, ) @@ -62,7 +65,7 @@ class DefaultMainAccountTokensMigrationTest { coVerify(inverse = true) { accountsResponseStoreFactory.create(any()) accountsResponseStore.data - userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any()) } } @@ -81,7 +84,7 @@ class DefaultMainAccountTokensMigrationTest { } coVerify(inverse = true) { - userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any()) } } @@ -110,7 +113,7 @@ class DefaultMainAccountTokensMigrationTest { } coVerify(inverse = true) { - userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any()) } } @@ -145,7 +148,7 @@ class DefaultMainAccountTokensMigrationTest { } coVerify(inverse = true) { - userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any()) + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any()) } } @@ -183,6 +186,8 @@ class DefaultMainAccountTokensMigrationTest { accountsResponseStoreFlow.value = response + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + // Act val actual = migration.migrate(userWalletId, derivationIndex) @@ -199,7 +204,8 @@ class DefaultMainAccountTokensMigrationTest { coVerifySequence { accountsResponseStoreFactory.create(userWalletId) accountsResponseStore.data - userTokensSaver.push( + accountsResponseStore.updateData(any()) + userTokensSaver.pushWithRetryer( userWalletId = userWalletId, response = migratedResponse.toUserTokensResponse(), onFailSend = any(), 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..674c15d34b 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,14 +3,14 @@ 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.account.utils.GetWalletAccountsResponseExtTest.Companion.createUserToken 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 import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* @@ -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,25 @@ class DefaultWalletAccountsResponseFactoryTest { every { walletId } returns userWalletId } - val defaultCoins = listOf(mockk()) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) - every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns defaultCoins - - val defaultResponse = UserTokensResponse( - group = UserTokensResponse.GroupType.NETWORK, - sort = UserTokensResponse.SortType.BALANCE, - tokens = listOf(mockk(relaxed = true)), - ) - - every { - userTokensResponseFactory.createUserTokensResponse( - currencies = defaultCoins, - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - } returns defaultResponse + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet val accounts = AccountList.empty(userWallet.walletId).accounts .filterIsInstance() + val token = createUserToken(accountIndex = 0) + val defaultResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = listOf(token), + ) + every { + userTokensResponseFactory.createDefaultResponse( + userWallet = userWallet, + networkFactory = networkFactory, + accountId = accounts.first().accountId, + ) + } returns defaultResponse + val accountsDTO = createWalletAccountDTO(userWalletId) every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) @@ -138,20 +136,19 @@ class DefaultWalletAccountsResponseFactoryTest { sort = defaultResponse.sort, totalAccounts = 1, ), - accounts = listOf(accountsDTO), + accounts = listOf(accountsDTO.copy(tokens = listOf(token))), unassignedTokens = emptyList(), ) 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 +159,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 @@ -202,24 +203,20 @@ class DefaultWalletAccountsResponseFactoryTest { val userWallet = mockk(relaxed = true) { 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, - tokens = listOf(mockk(relaxed = true)), + tokens = listOf( + createUserToken(accountIndex = 0), + createUserToken(accountIndex = 1), + ), ) - every { - userTokensResponseFactory.createUserTokensResponse( - currencies = assignedTokens, - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - } returns userTokensResponse val accounts = AccountList.empty(userWallet.walletId).accounts .filterIsInstance() - val accountsDTO = createWalletAccountDTO(userWalletId) + val accountsDTO = createWalletAccountDTO(userWalletId = userWalletId) every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) // Act @@ -232,7 +229,7 @@ class DefaultWalletAccountsResponseFactoryTest { sort = userTokensResponse.sort, totalAccounts = 1, ), - accounts = listOf(accountsDTO), + accounts = listOf(accountsDTO.copy(tokens = userTokensResponse.tokens)), unassignedTokens = emptyList(), ) Truth.assertThat(actual).isEqualTo(expected) 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..9124a77517 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 @@ -68,9 +70,9 @@ class GetWalletAccountsResponseExtTest { @Test fun `flattenTokens returns all tokens from multiple accounts`() { // Arrange - val token1 = createUserToken(id = "0") - val token2 = createUserToken(id = "1") - val token3 = createUserToken(id = "2") + val token1 = createUserToken(accountIndex = 0) + val token2 = createUserToken(accountIndex = 1) + val token3 = createUserToken(accountIndex = 2) val account1 = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1, token2)) val account2 = createWalletAccountDTO(derivationIndex = 1, tokens = listOf(token3)) @@ -129,10 +131,9 @@ class GetWalletAccountsResponseExtTest { @Test fun `toUserTokensResponse includes tokens from accounts and unassignedTokens`() { // Arrange - val token1 = createUserToken(id = "0") - val token2 = createUserToken(id = "1") + val token1 = createUserToken(accountIndex = 0) + val token2 = createUserToken(accountIndex = 1) val account = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1)) - val unassignedToken = token2 val response = GetWalletAccountsResponse( wallet = GetWalletAccountsResponse.Wallet( @@ -142,7 +143,7 @@ class GetWalletAccountsResponseExtTest { totalAccounts = 1, ), accounts = listOf(account), - unassignedTokens = listOf(unassignedToken), + unassignedTokens = listOf(token2), ) // Act @@ -167,8 +168,8 @@ class GetWalletAccountsResponseExtTest { fun `assignTokens correctly assigns tokens to accounts`() { // Arrange val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" - val token1 = createUserToken(id = "0", accountId = null) - val token2 = createUserToken(id = "1", accountId = null) + val token1 = createUserToken(accountIndex = 0, accountId = null) + val token2 = createUserToken(accountIndex = 1, accountId = null) val account1 = createWalletAccountDTO(derivationIndex = 0) val account2 = createWalletAccountDTO(derivationIndex = 1) val response = GetWalletAccountsResponse( @@ -192,10 +193,13 @@ class GetWalletAccountsResponseExtTest { account1.copy( tokens = listOf( token1.copy(accountId = accountId), + ), + ), + account2.copy( + tokens = listOf( token2.copy(accountId = accountId), ), ), - account2, ), unassignedTokens = emptyList(), ) @@ -230,6 +234,44 @@ class GetWalletAccountsResponseExtTest { Truth.assertThat(actual).isEqualTo(expected) } + + @Test + fun `unassignedTokens contain tokens for unexisting accounts`() { + // Arrange + val token1 = createUserToken(accountIndex = 0, accountId = null) + val token2 = createUserToken(accountIndex = 3, accountId = null) + val account = createWalletAccountDTO(derivationIndex = 0) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account), + unassignedTokens = listOf(token1, token2), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf( + account.copy( + tokens = listOf( + token1.copy(accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"), + token2.copy(accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"), + ), + ), + ), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } } @Nested @@ -240,8 +282,8 @@ class GetWalletAccountsResponseExtTest { fun `assignTokens correctly assigns tokens to accounts`() { // Arrange val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" - val token1 = createUserToken(id = "0", accountId = null) - val token2 = createUserToken(id = "1", accountId = null) + val token1 = createUserToken(accountIndex = 0, accountId = null) + val token2 = createUserToken(accountIndex = 1, accountId = null) val account1 = createWalletAccountDTO(derivationIndex = 0) val account2 = createWalletAccountDTO(derivationIndex = 1) @@ -256,10 +298,13 @@ class GetWalletAccountsResponseExtTest { account1.copy( tokens = listOf( token1.copy(accountId = accountId), + ), + ), + account2.copy( + tokens = listOf( token2.copy(accountId = accountId), ), ), - account2, ) Truth.assertThat(actual).isEqualTo(expected) @@ -269,7 +314,7 @@ class GetWalletAccountsResponseExtTest { fun `assignTokens does not change accounts if there are no unassignedTokens`() { // Arrange val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" - val token1 = createUserToken(id = "0", accountId = accountId) + val token1 = createUserToken(accountIndex = 0, accountId = accountId) val account1 = createWalletAccountDTO(derivationIndex = 0) // Act @@ -306,19 +351,19 @@ class GetWalletAccountsResponseExtTest { totalNetworks = 1, ) - private fun createUserToken(id: String, accountId: String? = "account_id") = UserTokensResponse.Token( - id = id, - accountId = accountId, - networkId = "ethereum", - derivationPath = "m/44'/60'/0'/0/0", - name = "Token", - symbol = "T", - contractAddress = "0x$id", - decimals = 18, - ) + companion object { - private companion object { + private val userWalletId = UserWalletId("011") - val userWalletId = UserWalletId("011") + fun createUserToken(accountIndex: Int, accountId: String? = "account_id") = UserTokensResponse.Token( + id = accountIndex.toString(), + accountId = accountId, + networkId = "ethereum", + derivationPath = "m/44'/60'/0'/0/$accountIndex", + name = "Token", + symbol = "T", + contractAddress = "0x$accountIndex", + decimals = 18, + ) } } \ No newline at end of file diff --git a/data/balance-hiding/src/main/java/com/tangem/data/balancehiding/DefaultDeviceFlipDetector.kt b/data/balance-hiding/src/main/java/com/tangem/data/balancehiding/DefaultDeviceFlipDetector.kt index f69cc30dd9..94dc429337 100644 --- a/data/balance-hiding/src/main/java/com/tangem/data/balancehiding/DefaultDeviceFlipDetector.kt +++ b/data/balance-hiding/src/main/java/com/tangem/data/balancehiding/DefaultDeviceFlipDetector.kt @@ -16,11 +16,19 @@ import javax.inject.Singleton @Singleton class DefaultDeviceFlipDetector @Inject constructor( - @ApplicationContext context: Context, + @ApplicationContext private val context: Context, ) : DeviceFlipDetector, DefaultLifecycleObserver { - private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager - private val gravitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) + private val sensorManager: SensorManager by lazy { + val manager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + + requireNotNull(manager) + } + + private val gravitySensor by lazy { + sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) + } + private val isResumedState = AtomicBoolean(false) override fun onPause(owner: LifecycleOwner) { diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index 9994bc2f46..b418e04abc 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -48,10 +48,7 @@ dependencies { /* Test */ testImplementation(projects.common.test) - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) + testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(deps.moshi) } \ No newline at end of file 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/api/ApiResponseRaise.kt b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt index bda26c7b94..4cdf5d1178 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt @@ -66,8 +66,8 @@ suspend inline fun safeApiCall( crossinline onError: suspend (ApiResponseError) -> T, ): T = recover( block = { call(ApiResponseRaise(raise = this)) }, - recover = { - Timber.e(it, "Unable to perform safe API call") - onError(it) + recover = { error -> + Timber.e(error, "Unable to perform safe API call") + onError(error) }, ) \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt index 90921f262e..3fe201e5de 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt @@ -18,9 +18,9 @@ internal class DefaultETagsStore( ) : ETagsStore { override suspend fun getSyncOrNull(userWalletId: UserWalletId, key: ETagsStore.Key): String? { - val key = getAccountsETagKey(userWalletId = userWalletId, key = key) + val storeKey = getAccountsETagKey(userWalletId = userWalletId, key = key) - return appPreferencesStore.getSyncOrNull(key = key) + return appPreferencesStore.getSyncOrNull(key = storeKey) } override suspend fun store(userWalletId: UserWalletId, key: ETagsStore.Key, value: String) { @@ -29,14 +29,14 @@ internal class DefaultETagsStore( return } - val key = getAccountsETagKey(userWalletId = userWalletId, key = key) + val storeKey = getAccountsETagKey(userWalletId = userWalletId, key = key) - appPreferencesStore.store(key = key, value = value) + appPreferencesStore.store(key = storeKey, value = value) } override suspend fun clear(userWalletId: UserWalletId, key: ETagsStore.Key) { - val key = getAccountsETagKey(userWalletId = userWalletId, key = key) - appPreferencesStore.editData { it.remove(key) } + val storeKey = getAccountsETagKey(userWalletId = userWalletId, key = key) + appPreferencesStore.editData { it.remove(storeKey) } } private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key { 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..14bb34f708 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,21 @@ 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 = blockchain, + extraDerivationPath = extraDerivationPath, + userWallet = userWallet, + accountIndex = accountIndex, + ) } else { Timber.e("Unable to get blockchain from chainId == $chainId") null @@ -89,6 +102,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 +113,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..626f998bfb 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 { @@ -84,7 +89,7 @@ internal class DefaultCardCryptoCurrencyFactory( } // single-currency wallet - return createPrimaryCurrencyForSingleCurrencyCard(userWallet).let(::listOf) + return listOf(createPrimaryCurrencyForSingleCurrencyCard(userWallet)) } override suspend fun createCurrenciesForMultiCurrencyCard( @@ -99,29 +104,11 @@ 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 + val blockchains = getDefaultWalletBlockchains(userWallet, demoConfig) - 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) - } - - return blockchains.mapNotNull { + return blockchains.mapNotNull { blockchain -> cryptoCurrencyFactory.createCoin( - blockchain = it, + blockchain = blockchain, extraDerivationPath = null, userWallet = userWallet, ) @@ -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/UserTokensResponseAddressesEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt index 2b155a84fc..aff3f5c5fa 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAddressesEnricher.kt @@ -27,7 +27,7 @@ class UserTokensResponseAddressesEnricher @Inject constructor( withTimeoutOrNull( FETCH_TIMEOUT_SECONDS.seconds, { multiNetworkStatusSupplier.invoke(MultiNetworkStatusProducer.Params(userWalletId)).first() }, - ) ?: emptySet() + ).orEmpty() } else { emptySet() } 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..85bf2f102f 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,40 @@ class UserTokensResponseFactory @Inject constructor() { ) } } + + fun createDefaultResponse( + userWallet: UserWallet?, + networkFactory: NetworkFactory, + accountId: AccountId?, + ): UserTokensResponse { + val tokens = if (userWallet != null) { + getDefaultWalletBlockchains(userWallet = userWallet, 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, + ) + } + } else { + emptyList() + } + + return UserTokensResponse( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + tokens = tokens, + ) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index e0bfb846d8..61f43d9ad7 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -4,18 +4,26 @@ import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.tokens.UserTokensBackwardCompatibility import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.WalletType 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.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.retryer.Retryer +import com.tangem.utils.retryer.RetryerPool import kotlinx.coroutines.withContext +import timber.log.Timber +@Suppress("LongParameterList") class UserTokensSaver( private val tangemTechApi: TangemTechApi, + private val userWalletsStore: UserWalletsStore, private val userTokensResponseStore: UserTokensResponseStore, private val dispatchers: CoroutineDispatcherProvider, private val addressesEnricher: UserTokensResponseAddressesEnricher, private val accountsFeatureToggles: AccountsFeatureToggles, + private val pushTokensRetryerPool: RetryerPool, ) { private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility() @@ -44,7 +52,12 @@ class UserTokensSaver( onFailSend: () -> Unit = {}, ) { withContext(dispatchers.default) { - val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher) + val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) + + val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy( + walletName = userWallet?.name, + walletType = WalletType.from(userWallet), + ) safeApiCall( call = { @@ -58,6 +71,23 @@ class UserTokensSaver( } } + suspend fun pushWithRetryer( + userWalletId: UserWalletId, + response: UserTokensResponse, + useEnricher: Boolean = true, + onFailSend: () -> Unit = {}, + ) { + push( + userWalletId = userWalletId, + response = response, + useEnricher = useEnricher, + onFailSend = { + pushTokensRetryerPool + createPushTokensRetryer(userWalletId, response) + onFailSend() + }, + ) + } + private fun UserTokensResponse.applyCompatibility(): UserTokensResponse { return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this) } @@ -70,11 +100,11 @@ class UserTokensSaver( return this .enrichByAddress(userWalletId = userWalletId) - .let { + .let { response -> if (accountsFeatureToggles.isFeatureEnabled) { - it.enrichByAccountId(userWalletId = userWalletId) + response.enrichByAccountId(userWalletId = userWalletId) } else { - it + response } } } @@ -86,4 +116,24 @@ class UserTokensSaver( private fun UserTokensResponse.enrichByAccountId(userWalletId: UserWalletId): UserTokensResponse { return UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, response = this) } + + private fun createPushTokensRetryer(userWalletId: UserWalletId, response: UserTokensResponse): Retryer { + return Retryer(attempt = 3) { iteration -> + var isSuccess = true + + push( + userWalletId = userWalletId, + response = response, + onFailSend = { + Timber.e( + "Retryer: Failed to push updated tokens on attempt ${iteration + 1} for $userWalletId", + ) + + isSuccess = false + }, + ) + + isSuccess + } + } } \ 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..fb49316219 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.* @@ -15,10 +16,13 @@ import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.retryer.RetryerPool 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,13 +34,17 @@ internal object DataCommonModule { fun provideCardCryptoCurrencyFactory( excludedBlockchains: ExcludedBlockchains, userWalletsStore: UserWalletsStore, + accountsFeatureToggles: AccountsFeatureToggles, + walletAccountsFetcher: WalletAccountsFetcher, userTokensResponseStore: UserTokensResponseStore, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ): CardCryptoCurrencyFactory { return DefaultCardCryptoCurrencyFactory( - demoConfig = DemoConfig(), + demoConfig = DemoConfig, excludedBlockchains = excludedBlockchains, userWalletsStore = userWalletsStore, + accountsFeatureToggles = accountsFeatureToggles, + walletAccountsFetcher = walletAccountsFetcher, userTokensResponseStore = userTokensResponseStore, responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, ) @@ -60,6 +68,7 @@ internal object DataCommonModule { @Singleton fun provideUserTokensSaver( tangemTechApi: TangemTechApi, + userWalletsStore: UserWalletsStore, userTokensResponseStore: UserTokensResponseStore, dispatchers: CoroutineDispatcherProvider, addressesEnricher: UserTokensResponseAddressesEnricher, @@ -67,10 +76,14 @@ internal object DataCommonModule { ): UserTokensSaver { return UserTokensSaver( tangemTechApi = tangemTechApi, + userWalletsStore = userWalletsStore, userTokensResponseStore = userTokensResponseStore, dispatchers = dispatchers, addressesEnricher = addressesEnricher, accountsFeatureToggles = accountsFeatureToggles, + pushTokensRetryerPool = RetryerPool( + coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default), + ), ) } 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..4ffb1c9210 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,18 +35,25 @@ 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, excludedBlockchains = excludedBlockchains, ), + accountIndex = accountIndex, ) } @@ -98,6 +108,7 @@ class NetworkFactory @Inject constructor( extraDerivationPath: String?, derivationStyleProvider: DerivationStyleProvider?, canHandleTokens: Boolean, + accountIndex: DerivationIndex? = null, ): Network? { return create( blockchain = blockchain, @@ -105,8 +116,10 @@ class NetworkFactory @Inject constructor( blockchain = blockchain, extraDerivationPath = extraDerivationPath, cardDerivationStyleProvider = derivationStyleProvider, + accountIndex = accountIndex, ), canHandleTokens = canHandleTokens, + accountIndex = accountIndex, ) } @@ -114,8 +127,10 @@ class NetworkFactory @Inject constructor( blockchain: Blockchain, derivationPath: Network.DerivationPath, canHandleTokens: Boolean, + accountIndex: DerivationIndex? = null, ): Network? { if (!blockchain.isBlockchainSupported()) return null + if (blockchain == Blockchain.Chia && accountIndex != DerivationIndex.Main) return null return runCatching { Network( @@ -148,14 +163,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()) { @@ -175,8 +191,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 +371,9 @@ 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, + Blockchain.Plasma, Blockchain.PlasmaTestnet, -> 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..8531497bf0 --- /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.getDemoBlockchains(card.cardId) + } 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..1d2b947799 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 @@ -8,11 +8,12 @@ import com.tangem.common.card.WalletData 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 @@ -21,6 +22,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.ProvideTestModels import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -37,15 +39,19 @@ 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(), + demoConfig = DemoConfig, excludedBlockchains = excludedBlockchains, userWalletsStore = userWalletsStore, userTokensResponseStore = userTokensResponseStore, 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/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt index bd7c253d03..8512f4f802 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt @@ -97,9 +97,9 @@ class UserTokensResponseAccountIdEnricherTest { val validNetworkId = mockCryptoCurrencyFactory.ethereum.network.rawId val tokenWithUnknownNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, networkId = unknownNetworkId, derivationPath = "m/44'/60'/0'/0/0", - accountId = null, ) val tokenWithValidNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken( @@ -122,6 +122,32 @@ class UserTokensResponseAccountIdEnricherTest { Truth.assertThat(actual).isEqualTo(expected) } + @Test + fun `skips tokens with existing account ids`() { + // Arrange + val tokenWithAccountId = mockCryptoCurrencyFactory.ethereum + .toResponseToken(derivationPath = "m/44'/60'/0'/0/0") + .enrichWithAccountId(accountIndex = 0) + + val tokenWithoutAccountId = mockCryptoCurrencyFactory.ethereum.toResponseToken( + accountId = null, + derivationPath = "m/44'/60'/0'/0/0", + ) + + val response = listOf(tokenWithAccountId, tokenWithoutAccountId).toResponse() + + // Act + val actual = UserTokensResponseAccountIdEnricher(userWalletId, response) + + // Assert + val expected = listOf( + tokenWithAccountId, + tokenWithoutAccountId.enrichWithAccountId(accountIndex = 0), + ).toResponse() + + Truth.assertThat(actual).isEqualTo(expected) + } + private fun CryptoCurrency.toResponseToken( accountId: AccountId? = null, networkId: String? = null, diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index 66e4d28c01..24c2d207e7 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -4,8 +4,11 @@ import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.WalletType 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.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -18,6 +21,7 @@ import org.junit.jupiter.api.TestInstance class UserTokensSaverTest { private val tangemTechApi: TangemTechApi = mockk() + private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true) private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true) private val enricher: UserTokensResponseAddressesEnricher = mockk() private val accountsFeatureToggles = mockk { @@ -26,15 +30,17 @@ class UserTokensSaverTest { private val userTokensSaver: UserTokensSaver = UserTokensSaver( tangemTechApi = tangemTechApi, + userWalletsStore = userWalletsStore, userTokensResponseStore = userTokensResponseStore, dispatchers = TestingCoroutineDispatcherProvider(), addressesEnricher = enricher, accountsFeatureToggles = accountsFeatureToggles, + pushTokensRetryerPool = mockk(), ) @BeforeEach fun resetMocks() { - clearMocks(tangemTechApi, userTokensResponseStore, enricher) + clearMocks(tangemTechApi, userWalletsStore, userTokensResponseStore, enricher) } @Test @@ -76,20 +82,31 @@ class UserTokensSaverTest { runTest { // GIVEN val userWalletId = UserWalletId("1234567890abcdef") + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.name } returns "Wallet" + } + val response = UserTokensResponse( version = 0, group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, tokens = emptyList(), + walletName = userWallet.name, + walletType = WalletType.COLD, ) val enrichedResponse = UserTokensResponse( version = 0, group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.MANUAL, tokens = emptyList(), + walletName = userWallet.name, + walletType = WalletType.COLD, ) val error = ApiResponseError.UnknownException(Exception("API Error")) var onFailSendCalled = false + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet coEvery { enricher(userWalletId, response) } returns enrichedResponse coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse @@ -113,18 +130,29 @@ class UserTokensSaverTest { fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest { // GIVEN val userWalletId = UserWalletId("1234567890abcdef") + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.name } returns "Wallet" + } + val response = UserTokensResponse( version = 0, group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, tokens = emptyList(), + walletName = userWallet.name, + walletType = WalletType.COLD, ) val enrichedResponse = UserTokensResponse( version = 0, group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, tokens = emptyList(), + walletName = userWallet.name, + walletType = WalletType.COLD, ) + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet coEvery { enricher(userWalletId, response) } returns enrichedResponse coEvery { tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse) diff --git a/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt index 4cbcb9a0a5..e0fc158abe 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt @@ -6,14 +6,14 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains 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.domain.wallets.derivations.DerivationStyleProvider import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.card.configs.MultiWalletCardConfig -import com.tangem.domain.wallets.derivations.derivationStyleProvider import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.derivations.DerivationStyleProvider +import com.tangem.domain.wallets.derivations.derivationStyleProvider +import com.tangem.test.core.ProvideTestModels import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Nested diff --git a/data/express/src/main/java/com/tangem/data/express/DefaultExpressRepository.kt b/data/express/src/main/java/com/tangem/data/express/DefaultExpressRepository.kt index 7fecd6950c..31d7577acf 100644 --- a/data/express/src/main/java/com/tangem/data/express/DefaultExpressRepository.kt +++ b/data/express/src/main/java/com/tangem/data/express/DefaultExpressRepository.kt @@ -35,9 +35,9 @@ internal class DefaultExpressRepository( ).getOrThrow().map(ExpressProviderConverter()::convert) .filterIf(filterProviderTypes.isNotEmpty()) { it.type in filterProviderTypes } }, - onError = { - Timber.w(it, "Unable to fetch express providers") - throw it + onError = { error -> + Timber.w(error, "Unable to fetch express providers") + throw error }, ) } 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 64% 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..e794c648b0 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,12 +99,12 @@ 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 -> statuses.toMutableMap().apply { - put(key = userWalletId, value = default) + this[userWalletId] = default } } 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/feedback/detekt-baseline-debug.xml b/data/feedback/detekt-baseline-debug.xml new file mode 100644 index 0000000000..596e50e191 --- /dev/null +++ b/data/feedback/detekt-baseline-debug.xml @@ -0,0 +1,9 @@ + + + + + BooleanPropertyNaming:DefaultFeedbackRepository.kt$DefaultFeedbackRepository$private val useNewUserWalletsRepository: Boolean + MultilineLambdaItParameter:DefaultFeedbackRepository.kt$DefaultFeedbackRepository${ it.toMutableMap().apply { put(userWallet.walletId, error) } } + UseOrEmpty:BlockchainInfoConverter.kt$BlockchainInfoConverter$value.wallet.publicKey.derivationPath?.rawPath ?: "" + + diff --git a/data/hot-wallet/.gitignore b/data/hot-wallet/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/data/hot-wallet/.gitignore @@ -0,0 +1 @@ +/build diff --git a/data/hot-wallet/build.gradle.kts b/data/hot-wallet/build.gradle.kts new file mode 100644 index 0000000000..285d04e10c --- /dev/null +++ b/data/hot-wallet/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.data.hotwallet" +} + +dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + + implementation(projects.domain.hotWallet) + implementation(projects.domain.models) + + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + implementation(deps.androidx.datastore) + implementation(deps.kotlin.coroutines) + implementation(deps.moshi) + implementation(deps.moshi.kotlin) +} \ No newline at end of file diff --git a/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/DefaultHotWalletRepository.kt b/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/DefaultHotWalletRepository.kt new file mode 100644 index 0000000000..4670884954 --- /dev/null +++ b/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/DefaultHotWalletRepository.kt @@ -0,0 +1,28 @@ +package com.tangem.data.hotwallet + +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class DefaultHotWalletRepository( + private val appPreferencesStore: AppPreferencesStore, +) : HotWalletRepository { + + override fun accessCodeSkipped(userWalletId: UserWalletId): Flow = appPreferencesStore + .getObjectMap(PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY) + .map { it[userWalletId.stringValue] == true } + + override suspend fun setAccessCodeSkipped(userWalletId: UserWalletId, skipped: Boolean) { + appPreferencesStore.editData { mutablePreferences -> + mutablePreferences.setObjectMap( + key = PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY, + value = mutablePreferences.getObjectMap(PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY) + .plus(userWalletId.stringValue to skipped), + ) + } + } +} \ No newline at end of file diff --git a/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/di/HotWalletDataModule.kt b/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/di/HotWalletDataModule.kt new file mode 100644 index 0000000000..f8c678c0e2 --- /dev/null +++ b/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/di/HotWalletDataModule.kt @@ -0,0 +1,23 @@ +package com.tangem.data.hotwallet.di + +import com.tangem.data.hotwallet.DefaultHotWalletRepository +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.hotwallet.repository.HotWalletRepository +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 HotWalletDataModule { + + @Provides + @Singleton + fun provideHotWalletRepository(appPreferencesStore: AppPreferencesStore): HotWalletRepository { + return DefaultHotWalletRepository( + appPreferencesStore = appPreferencesStore, + ) + } +} \ 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/detekt-baseline-debug.xml b/data/manage-tokens/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d384bf15ce --- /dev/null +++ b/data/manage-tokens/detekt-baseline-debug.xml @@ -0,0 +1,17 @@ + + + + + MultilineLambdaItParameter:DefaultCustomTokensRepository.kt$DefaultCustomTokensRepository${ // TODO: refactor https://tangem.atlassian.net/browse/AND-10006\ if (it.isTestnet() || it in excludedBlockchains || it in hotWalletExcludedBlockchains) { return@mapNotNull null } networkFactory.create( blockchain = it, extraDerivationPath = null, userWallet = userWallet, ) } + MultilineLambdaItParameter:DefaultManageTokensRepository.kt$DefaultManageTokensRepository${ it.contractAddress != null && it.networkId == network.backendId && it.derivationPath == network.derivationPath.value } + MultilineLambdaItParameter:ManageTokensUpdateFetcher.kt$ManageTokensUpdateFetcher${ if (it.key == toUpdate[index].key) { Batch(it.key, updatedItems) } else { null } } + NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$create(coinsResponse, tokensResponse, userWallet, accountIndex) + NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet, accountIndex) + NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$findAddedInNetworks(coinResponse.id, tokensResponse, userWallet, accountIndex) + NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$findAddedInNetworks(testnetToken.id, tokensResponse, userWallet, accountIndex) + SuspendFunSwallowedCancellation:DefaultManageTokensRepository.kt$DefaultManageTokensRepository$runCatching + UnsafeCallOnNullableType:DefaultCustomTokensRepository.kt$DefaultCustomTokensRepository$coinNetwork.decimalCount!! + UseOrEmpty:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$testnetToken.networks?.mapNotNull { network -> createSource( networkId = network.id, contractAddress = network.address, decimals = network.decimalCount, userWallet = userWallet, accountIndex = accountIndex, ) } ?: emptyList() + UseOrEmpty:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$tokensResponse ?.let { createCustomTokens(it, userWallet, accountIndex) } ?: emptyList() + + 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..ed2887e114 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,21 @@ 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.api.tangemTech.models.orDefault 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 +42,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 +53,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 +90,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 +105,6 @@ internal class DefaultManageTokensRepository( }, ) - @Suppress("ComplexCondition") private suspend fun fetchCurrencies( userWallet: UserWallet?, request: Request, @@ -127,31 +137,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 +163,117 @@ internal class DefaultManageTokensRepository( ) } + @Suppress("CyclomaticComplexMethod") + private suspend fun createManagedCryptoCurrencyList( + params: ManageTokensListConfig.Account, + userWallet: UserWallet?, + isFirstBatchFetching: Boolean, + loadUserTokensFromRemote: Boolean, + query: String?, + updatedCoinsResponse: CoinsResponse, + ): List { + val response = params.userWalletId?.let { userWalletId -> + val shouldFetch = loadUserTokensFromRemote && userWallet != null + + val fetchedResponse = if (shouldFetch) { + runCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull() + } else { + null + } + + fetchedResponse ?: 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.orDefault(), + sort = response.wallet.sort.orDefault(), + 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 +282,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.orDefault(), + sort = response.wallet.sort.orDefault(), + 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 +350,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/detekt-baseline-debug.xml b/data/markets/detekt-baseline-debug.xml new file mode 100644 index 0000000000..fd96850854 --- /dev/null +++ b/data/markets/detekt-baseline-debug.xml @@ -0,0 +1,16 @@ + + + + + BooleanPropertyNaming:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository.<no name provided>$val last = res.tokens.size < request.limit + MultilineLambdaItParameter:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository${ val error = it as QuotesFetcher.Error.ApiOperationError val errorEvent = createDetailsErrorEvent( error = error.apiError, request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, tokenSymbol = tokenSymbol, ) analyticsEventHandler.send(errorEvent.toEvent()) throw error.apiError } + MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ it.copy( tokenCharts = TokenMarketChartsConverter.convert( chartsToCopy = it.tokenCharts, tokenId = it.id, interval = updateRequest.interval, value = update, ), ) } + MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ val exception = if (it is QuotesFetcher.Error.ApiOperationError) { onApiResponseError(it.apiError) it.apiError } else { error("Cause: $it") } throw exception } + MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ TokenMarketInfo.Link( title = it.title, id = it.id, link = it.link, ) } + NullableBooleanCheck:TokenMarketListConverter.kt$TokenMarketListConverter$token.isUnderMarketCapLimit ?: false + SuspendFunWithFlowReturnType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$suspend + UnsafeCallOnNullableType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$network.contractAddress!! + UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.Details$mapOf() + UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.List$mapOf() + + diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index 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/networks/build.gradle.kts b/data/networks/build.gradle.kts index 4653fb36ff..c61859dc69 100644 --- a/data/networks/build.gradle.kts +++ b/data/networks/build.gradle.kts @@ -48,13 +48,10 @@ dependencies { // endregion // region Tests - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(tangemDeps.blockchain) testImplementation(tangemDeps.card.core) testImplementation(projects.common.test) + testImplementation(projects.test.core) // endregion } \ No newline at end of file diff --git a/data/networks/detekt-baseline-debug.xml b/data/networks/detekt-baseline-debug.xml new file mode 100644 index 0000000000..981dd137d6 --- /dev/null +++ b/data/networks/detekt-baseline-debug.xml @@ -0,0 +1,15 @@ + + + + + MultilineLambdaItParameter:CommonNetworkStatusFetcher.kt$CommonNetworkStatusFetcher${ Timber.e("Failed to fetch network status for $userWalletId [${network.rawId}]: $it") networksStatusesStore.setSourceAsOnlyCache(userWalletId = userWalletId, network = network) } + MultilineLambdaItParameter:DefaultMultiNetworkStatusFetcher.kt$DefaultMultiNetworkStatusFetcher${ networksStatusesStore.setSourceAsOnlyCache( userWalletId = params.userWalletId, networks = params.networks, ) raise(it) } + MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return emptyList() } + MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return@withContext } + MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null CurrencyAmount( id = currencyIdConverter.convertBack(value = it.key), amount = amount.value, ) } + MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val currencyId = currencyIdConverter.convert(value = it.id) val amount = NetworkStatus.Amount.Loaded(value = it.amount) currencyId to amount } + MultilineLambdaItParameter:NetworkStatusSupplierModule.kt$NetworkStatusSupplierModule.<no name provided>${ "single_network_status_${it.userWalletId.stringValue}_${it.network.rawId}_" + it.network.derivationPath.value } + MultilineLambdaItParameter:NetworkYieldSupplyStatusConverter.kt$NetworkYieldSupplyStatusConverter${ val id = currencyIdConverter.convert(value = it.id) val status = YieldSupplyStatus( isActive = it.isActive, isInitialized = it.isInitialized, isAllowedToSpend = it.isAllowedToSpend, effectiveProtocolBalance = it.effectiveProtocolBalance, ) id to status } + SuspendFunSwallowedCancellation:DefaultNetworksRepository.kt$DefaultNetworksRepository$runCatching + + diff --git a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt index 1209bcfb5b..0ec3c30598 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt @@ -79,6 +79,10 @@ internal class DefaultNetworksRepository( } } + override suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean { + return networksStatusesStore.contains(userWalletId) + } + private suspend fun fetchPendingTransactions( userWalletId: UserWalletId, network: Network, diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt index 9cba360e2e..1131c43dfc 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt @@ -124,6 +124,10 @@ internal class DefaultNetworksStatusesStore( } } + override suspend fun contains(userWalletId: UserWalletId): Boolean { + return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue) + } + private suspend fun updateInRuntime( userWalletId: UserWalletId, networks: Set, diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt index 680ee077ab..2e63a27f7b 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt @@ -44,4 +44,7 @@ internal interface NetworksStatusesStore { /** Clear statuses of [networks] by [userWalletId] */ suspend fun clear(userWalletId: UserWalletId, networks: Set) + + /** Check if there are statuses for given [userWalletId] */ + suspend fun contains(userWalletId: UserWalletId): Boolean } \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt index dbc7712de6..9cdcc4810e 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt @@ -1,10 +1,10 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt index d4866eeabf..87be4c1708 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt @@ -1,9 +1,9 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.network.NetworkAddress +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkDerivationPathConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkDerivationPathConverterTest.kt index a2502860b0..6d92ebd874 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkDerivationPathConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkDerivationPathConverterTest.kt @@ -3,10 +3,10 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.network.Network +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource /** [REDACTED_AUTHOR] @@ -18,7 +18,7 @@ class NetworkDerivationPathConverterTest { inner class Convert { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun convert(model: ConvertModel) { // Act val actual = NetworkDerivationPathConverter.convert(value = model.value) @@ -57,7 +57,7 @@ class NetworkDerivationPathConverterTest { inner class ConvertBack { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun convertBack(model: ConvertBackModel) { // Act val actual = NetworkDerivationPathConverter.convertBack(value = model.value) diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt index c42a8a9a9d..97265f47a1 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt @@ -2,7 +2,6 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.datasource.local.network.entity.NetworkStatusDM.* import com.tangem.domain.models.StatusSource @@ -14,6 +13,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index 5e7375de67..1b57d19996 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -2,7 +2,6 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.datasource.local.network.entity.NetworkStatusDM.* @@ -13,6 +12,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.network.NetworkStatus.Amount import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal diff --git a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt index f45a3fae53..e6f9bf012d 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt @@ -7,7 +7,6 @@ import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.walletmanager.MockUpdateWalletManagerResultFactory -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.setSourceAsOnlyCache import com.tangem.data.networks.store.storeStatus @@ -16,6 +15,7 @@ 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.walletmanager.WalletManagersFacade +import com.tangem.test.core.ProvideTestModels import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt index 6792d2a66c..76bdc3f68a 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt @@ -3,7 +3,6 @@ package com.tangem.data.networks.multi import arrow.core.Either import arrow.core.left import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.assertEither import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.store.NetworksStatusesStore @@ -11,6 +10,7 @@ import com.tangem.data.networks.store.setSourceAsCache import com.tangem.data.networks.store.setSourceAsOnlyCache import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.test.core.assertEither import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt index 715522ea79..a54bcb56c7 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt @@ -5,7 +5,6 @@ import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.common.network.NetworkFactory import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.data.networks.store.NetworksStatusesStore @@ -14,6 +13,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.networks.multi.MultiNetworkStatusProducer +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.* diff --git a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcherTest.kt index c5093952bb..57e1cd07cb 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcherTest.kt @@ -3,10 +3,10 @@ package com.tangem.data.networks.single import arrow.core.Either import arrow.core.left import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.assertEither import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.test.core.assertEither import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify diff --git a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt index 0a0b7c5ece..eab6ea0a8f 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt @@ -3,12 +3,12 @@ package com.tangem.data.networks.single import com.google.common.truth.Truth import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.getEmittedValues import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusProducer +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt index 17c16671d5..7a8ab68799 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt @@ -5,11 +5,11 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.datastore.MockStateDataStore import com.tangem.common.test.domain.network.MockNetworkStatusFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.data.networks.toSimple import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.mockk import kotlinx.coroutines.test.runTest diff --git a/data/news/.gitignore b/data/news/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/news/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/news/build.gradle.kts b/data/news/build.gradle.kts new file mode 100644 index 0000000000..86796fcedd --- /dev/null +++ b/data/news/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.news" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + // region Project - Core + implementation(projects.core.datasource) + api(projects.core.utils) + // endregion + + // region Project - Data + implementation(projects.data.common) + // endregion + + // region Project - Domain + implementation(projects.domain.news) + // endregion + + // region Project - Libs + implementation(projects.libs.blockchainSdk) + // endregion + + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // endregion + + // region Other libraries + implementation(deps.androidx.datastore) + implementation(deps.moshi.kotlin) + implementation(deps.timber) + // endregion + + // region Tests + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) + // endregion +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt new file mode 100644 index 0000000000..9a5aa274f1 --- /dev/null +++ b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt @@ -0,0 +1,34 @@ +package com.tangem.data.news.di + +import com.tangem.data.news.repository.DefaultNewsRepository +import com.tangem.datasource.api.news.NewsApi +import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.domain.news.repository.NewsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +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 NewsDataModule { + + @Singleton + @Provides + fun provideNewsRepository( + newsApi: NewsApi, + dispatchers: CoroutineDispatcherProvider, + newsDetailsStore: NewsDetailsStore, + trendingNewsStore: TrendingNewsStore, + ): NewsRepository { + return DefaultNewsRepository( + newsApi = newsApi, + dispatchers = dispatchers, + newsDetailsStore = newsDetailsStore, + trendingNewsStore = trendingNewsStore, + ) + } +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt new file mode 100644 index 0000000000..e42068534e --- /dev/null +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -0,0 +1,279 @@ +package com.tangem.data.news.repository + +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.news.NewsApi +import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.domain.news.model.NewsListBatchFlow +import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.repository.NewsRepository +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListSource +import com.tangem.pagination.exception.EndOfPaginationException +import com.tangem.pagination.fetcher.BatchFetcher +import com.tangem.pagination.toBatchFlow +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.collections.orEmpty + +/** + * Implementation of [NewsRepository]. +[REDACTED_AUTHOR] + */ +internal class DefaultNewsRepository @Inject constructor( + private val newsApi: NewsApi, + private val dispatchers: CoroutineDispatcherProvider, + private val newsDetailsStore: NewsDetailsStore, + private val trendingNewsStore: TrendingNewsStore, +) : NewsRepository { + + override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { + return BatchListSource( + fetchDispatcher = dispatchers.io, + context = context, + generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY }, + batchFetcher = createBatchFetcher(batchSize), + ).toBatchFlow() + } + + override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { + val cached = newsDetailsStore.getSyncOrNull(newsId) + if (cached != null) return cached + + fetchDetailedArticlesInternal(newsIds = listOf(newsId), language = language) + + return requireNotNull(newsDetailsStore.getSyncOrNull(newsId)) { + "Unable to load detailed article with id=$newsId" + } + } + + override fun observeDetailedArticles(): Flow> { + return newsDetailsStore.getAll().map { articles -> + articles.associateBy(DetailedArticle::id) + } + } + + override suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) { + fetchDetailedArticlesInternal(newsIds = newsIds, language = language) + } + + override suspend fun getTrendingNews(limit: Int, language: String?): List { + return fetchAndStoreTrendingNews(limit = limit, language = language) + } + + override fun observeTrendingNews(): Flow> { + return trendingNewsStore.get(TRENDING_NEWS_KEY) + } + + override suspend fun refreshTrendingNews(limit: Int, language: String?) { + fetchAndStoreTrendingNews(limit = limit, language = language) + } + + override suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) { + if (articleIds.isEmpty()) return + + val current = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty() + if (current.isEmpty()) return + + val ids = articleIds.toSet() + val updated = current.map { article -> + if (article.id in ids) { + article.copy(viewed = viewed) + } else { + article + } + } + + trendingNewsStore.store(TRENDING_NEWS_KEY, updated) + } + + override suspend fun getCategories(): List { + return newsApi.getCategories().getOrThrow().items.map { dto -> + ArticleCategory( + id = dto.id, + name = dto.name, + ) + } + } + + private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = + withContext(dispatchers.io) { + if (newsIds.isEmpty()) return@withContext + + val uniqueIds = newsIds.distinct() + val idsToFetch = buildList { + for (id in uniqueIds) { + val cached = newsDetailsStore.getSyncOrNull(id) + if (cached == null) add(id) + } + } + + if (idsToFetch.isEmpty()) return@withContext + + val fetchedArticles = coroutineScope { + idsToFetch.map { newsId -> + async { + newsApi.getNewsDetails(newsId = newsId, language = language) + .getOrThrow() + .toDomainDetailedArticle() + } + }.awaitAll() + } + + if (fetchedArticles.isNotEmpty()) { + newsDetailsStore.store( + articles = fetchedArticles.associateBy(DetailedArticle::id), + ) + } + } + + private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?): List { + return withContext(dispatchers.io) { + val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow() + val freshArticles = response.items.map { it.toDomainShortArticle() } + val currentArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty() + val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit) + + trendingNewsStore.store(TRENDING_NEWS_KEY, merged) + + merged + } + } + + private fun mergeTrendingArticles(current: List, fresh: List): List { + if (current.isEmpty()) return fresh + + val currentById = current.associateBy(ShortArticle::id) + + return fresh.map { article -> + val stored = currentById[article.id] ?: return@map article + article.copy(viewed = stored.viewed) + } + } + + private fun createBatchFetcher(batchSize: Int): BatchFetcher> { + return NewsBatchFetcher( + newsApi = newsApi, + batchSize = batchSize, + ) + } + + private class NewsBatchFetcher( + private val newsApi: NewsApi, + private val batchSize: Int, + ) : BatchFetcher> { + + private var state: NewsPaginationState? = null + + override suspend fun fetchFirst(requestParams: NewsListConfig): BatchFetchResult> { + return runSuspendCatching { + loadPage( + page = FIRST_PAGE, + params = requestParams, + limit = batchSize, + snapshotOverride = requestParams.snapshot, + ) + }.fold( + onSuccess = { result -> + state = result.state + result.batchResult + }, + onFailure = { throwable -> BatchFetchResult.Error(throwable) }, + ) + } + + override suspend fun fetchNext( + overrideRequestParams: NewsListConfig?, + lastResult: BatchFetchResult>, + ): BatchFetchResult> { + val currentState = state ?: return BatchFetchResult.Error( + IllegalStateException("fetchFirst must be called"), + ) + + if (lastResult is BatchFetchResult.Success && lastResult.last && overrideRequestParams == null) { + return BatchFetchResult.Error(EndOfPaginationException()) + } + + val params = overrideRequestParams ?: currentState.params + val shouldReset = overrideRequestParams != null && overrideRequestParams != currentState.params + val pageToLoad = if (shouldReset) FIRST_PAGE else currentState.nextPage + val snapshot = if (shouldReset) overrideRequestParams.snapshot else currentState.snapshot + + return runSuspendCatching { + loadPage( + page = pageToLoad, + params = params, + limit = batchSize, + snapshotOverride = snapshot, + ) + }.fold( + onSuccess = { result -> + state = result.state + result.batchResult + }, + onFailure = { throwable -> BatchFetchResult.Error(throwable) }, + ) + } + + private suspend fun loadPage( + page: Int, + params: NewsListConfig, + limit: Int, + snapshotOverride: String?, + ): PageLoadResult { + val response = newsApi.getNews( + page = page, + limit = limit, + language = params.language, + snapshot = snapshotOverride, + tokenIds = params.tokenIds.takeIf { it.isNotEmpty() }, + categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, + ).getOrThrow() + + val items = response.items.map { it.toDomainShortArticle() } + + val batchResult = BatchFetchResult.Success( + data = items, + empty = items.isEmpty(), + last = response.meta.hasNext.not(), + ) + + return PageLoadResult( + batchResult = batchResult, + state = NewsPaginationState( + nextPage = response.meta.page + 1, + snapshot = response.meta.asOf, + params = params.copy(snapshot = response.meta.asOf), + ), + ) + } + } + + private data class PageLoadResult( + val batchResult: BatchFetchResult.Success>, + val state: NewsPaginationState, + ) + + private data class NewsPaginationState( + val nextPage: Int, + val snapshot: String?, + val params: NewsListConfig, + ) + + private companion object { + private const val INITIAL_BATCH_KEY = 0 + private const val FIRST_PAGE = 1 + private const val TRENDING_NEWS_KEY = "trending_news" + } +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt new file mode 100644 index 0000000000..6f34dd3ed4 --- /dev/null +++ b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt @@ -0,0 +1,63 @@ +package com.tangem.data.news.repository + +import com.tangem.datasource.api.news.models.response.NewsArticleDto +import com.tangem.datasource.api.news.models.response.NewsDetailsResponse +import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto +import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.OriginalArticle +import com.tangem.domain.models.news.RelatedToken +import com.tangem.domain.models.news.ShortArticle + +internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { + return DetailedArticle( + id = id, + createdAt = createdAt, + score = score.toFloat(), + locale = language, + isTrending = isTrending, + categories = categories.map { ArticleCategory(id = it.id, name = it.name) }, + relatedTokens = relatedTokens.map { it.toDomainRelatedToken() }, + title = title, + newsUrl = newsUrl, + shortContent = shortContent, + content = content, + originalArticles = originalArticles.map { it.toDomainOriginalArticle() }, + ) +} + +internal fun NewsArticleDto.toDomainShortArticle(): ShortArticle { + return ShortArticle( + id = id, + createdAt = createdAt, + score = score.toFloat(), + locale = language, + categories = categories.map { ArticleCategory(id = it.id, name = it.name) }, + relatedTokens = relatedTokens.map { it.toDomainRelatedToken() }, + isTrending = isTrending, + title = title, + newsUrl = newsUrl, + viewed = false, + ) +} + +internal fun NewsRelatedTokenDto.toDomainRelatedToken(): RelatedToken { + return RelatedToken( + id = id, + symbol = symbol, + name = name, + ) +} + +internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle { + return OriginalArticle( + id = id, + title = title, + sourceName = sourceName, + locale = language, + publishedAt = publishedAt, + url = url, + imageUrl = imageUrl, + ) +} \ No newline at end of file diff --git a/data/nft/detekt-baseline-debug.xml b/data/nft/detekt-baseline-debug.xml new file mode 100644 index 0000000000..08a3e3aba8 --- /dev/null +++ b/data/nft/detekt-baseline-debug.xml @@ -0,0 +1,26 @@ + + + + + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ NFTCollections( network = network, content = NFTCollections.Content.Collections( collections = it ?.map { collection -> nftSdkCollectionConverter.convert(network to collection) } ?.filter { it.id !is NFTCollection.Identifier.Unknown }, source = StatusSource.CACHE, ), ) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ getNFTPersistenceStore(userWalletId, it).clear() getNFTRuntimeStore(userWalletId, it).clear() } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it !is UnsupportedOperationException) { saveFailedStateInRuntime( userWalletId = userWalletId, network = network, error = it, ) } } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it.id == collectionId) { it.changeAssetsStatusSource(source) } else { it } } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it.identifier == sdkCollectionId) { it.copy(assets = assets) } else { it } } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ networkFactory.create( blockchain = it, extraDerivationPath = null, userWallet = userWallet, ) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ nftRuntimeStores[storeId] = it val storedCollections = getStoredCollections(userWalletId, network) val storedPrices = getStoredPrices(userWalletId, network) it.initialize( collections = storedCollections, prices = storedPrices, ) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ saveCollectionsInRuntime( userWalletId = userWalletId, network = network, collections = it, ) saveCollectionsInPersistence( userWalletId = userWalletId, network = network, collections = it, ) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ saveFailedStateInRuntime( userWalletId = userWalletId, network = network, error = it, ) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ storedCollections.copy( content = it, ) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val (assetId, _) = it assetIdConverter.convert(assetId) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val (assetId, price) = it val nftCurrency = getNFTCurrency(network) NFTSalePrice.Value( assetId = assetId, value = price.value, fiatValue = null, symbol = nftCurrency.symbol, decimals = nftCurrency.decimals, ) } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val assetId = assetIdConverter.convert(it.identifier) val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId) if (price is NFTSalePrice.Empty || price is NFTSalePrice.Error) { refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier) } } + MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val sdkAssetId = assetIdConverter.convertBack(assetId) saveSalePriceInPersistence(userWalletId, network, sdkAssetId, it) } + NamedArguments:DefaultNFTRepository.kt$DefaultNFTRepository$refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier) + NamedArguments:DefaultNFTRepository.kt$DefaultNFTRepository$saveSalePriceInPersistence(userWalletId, network, sdkAssetId, it) + NoNameShadowing:DefaultNFTRepository.kt$DefaultNFTRepository${ it.id !is NFTCollection.Identifier.Unknown } + SuspendFunSwallowedCancellation:DefaultNFTRepository.kt$DefaultNFTRepository$runCatching + SuspendFunWithFlowReturnType:DefaultNFTRepository.kt$DefaultNFTRepository$suspend + UnnecessaryLet:DefaultNFTRepository.kt$DefaultNFTRepository$let { prices -> prices .mapKeys { val (assetId, _) = it assetIdConverter.convert(assetId) } .mapValues { val (assetId, price) = it val nftCurrency = getNFTCurrency(network) NFTSalePrice.Value( assetId = assetId, value = price.value, fiatValue = null, symbol = nftCurrency.symbol, decimals = nftCurrency.decimals, ) } } + + 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/detekt-baseline-debug.xml b/data/onramp/detekt-baseline-debug.xml new file mode 100644 index 0000000000..ec3fa17add --- /dev/null +++ b/data/onramp/detekt-baseline-debug.xml @@ -0,0 +1,17 @@ + + + + + BooleanPropertyNaming:DefaultOnrampRepository.kt$DefaultOnrampRepository$val cachedValue = onrampSepaAvailabilityStore.getSyncOrNull(key) + MultilineLambdaItParameter:DefaultHotCryptoRepository.kt$DefaultHotCryptoRepository${ Timber.e(it, "Unable to fetch hot crypto") analyticsEventHandler.send( event = MainScreenAnalyticsEvent.HotTokenError( errorCode = (it as? ApiResponseError.HttpException)?.code?.numericCode?.toString().orEmpty(), ), ) } + MultilineLambdaItParameter:DefaultHotCryptoRepository.kt$DefaultHotCryptoRepository${ if (it.id == OLD_POLYGON_NAME) { it.copy(id = NEW_POLYGON_NAME) } else { it } } + MultilineLambdaItParameter:DefaultHotCryptoRepository.kt$DefaultHotCryptoRepository${ val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("UserWalletId [$userWalletId] not found") HotCryptoCurrencyConverter( userWallet = userWallet, imageHost = it.imageHost, excludedBlockchains = excludedBlockchains, ) .convertList(input = it.tokens) .filterNotNull() } + MultilineLambdaItParameter:DefaultOnrampRepository.kt$DefaultOnrampRepository${ Timber.w(it, "Unable to fetch express providers") throw it } + MultilineLambdaItParameter:DefaultOnrampRepository.kt$DefaultOnrampRepository${ Timber.w(it, "Unable to fetch onramp pairs") throw it } + MultilineLambdaItParameter:DefaultOnrampRepository.kt$DefaultOnrampRepository${ Timber.w(it, "Unable to fetch onramp payment methods") throw it } + UnnecessaryLet:DefaultOnrampRepository.kt$DefaultOnrampRepository$let(countryConverter::convert) + UnnecessaryLet:DefaultOnrampRepository.kt$DefaultOnrampRepository$let(paymentMethodsConverter::convertList) + UnnecessaryLet:DefaultOnrampRepository.kt$DefaultOnrampRepository$let(statusConverter::convert) + UseOrEmpty:DefaultOnrampTransactionRepository.kt$DefaultOnrampTransactionRepository$stored ?: emptySet() + + 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/legacy/MercuryoBlockchainMapping.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt index 66ee3d8bb8..9b9f34c2cf 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,8 @@ 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 + Blockchain.Plasma, Blockchain.PlasmaTestnet -> null } } \ No newline at end of file diff --git a/data/promo/detekt-baseline-debug.xml b/data/promo/detekt-baseline-debug.xml new file mode 100644 index 0000000000..0f6f378450 --- /dev/null +++ b/data/promo/detekt-baseline-debug.xml @@ -0,0 +1,10 @@ + + + + + NullableBooleanCheck:DefaultPromoRepository.kt$DefaultPromoRepository$getSepaPromoBanner()?.isActive ?: false + NullableBooleanCheck:DefaultPromoRepository.kt$DefaultPromoRepository$getVisaPromoBanner()?.isActive ?: false + SuspendFunSwallowedCancellation:DefaultPromoRepository.kt$DefaultPromoRepository$runCatching + SuspendFunWithFlowReturnType:DefaultPromoRepository.kt$DefaultPromoRepository$suspend + + diff --git a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt index bfdffde151..86c501406d 100644 --- a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt +++ b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt @@ -19,10 +19,7 @@ import com.tangem.domain.promo.models.StoryContent import com.tangem.feature.referral.domain.ReferralRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runCatching -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.* import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -41,28 +38,30 @@ internal class DefaultPromoRepository( return appPreferencesStore.get( key = PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), default = true, - ).map { shouldShow -> - when (promoId) { - PromoId.Referral -> runCatching { - !referralRepository.isReferralParticipant(userWalletId) && shouldShow - }.getOrDefault(false) - PromoId.Sepa -> { - val isActive = getSepaPromoBanner()?.isActive ?: false + ) + .distinctUntilChanged() + .map { shouldShow -> + when (promoId) { + PromoId.Referral -> runCatching { + !referralRepository.isReferralParticipant(userWalletId) && shouldShow + }.getOrDefault(false) + PromoId.Sepa -> { + val isActive = getSepaPromoBanner()?.isActive == true - isActive && shouldShow - } - PromoId.VisaPresale -> { - val isActive = getVisaPromoBanner()?.isActive ?: false + isActive && shouldShow + } + PromoId.VisaPresale -> { + val isActive = getVisaPromoBanner()?.isActive == true - isActive && shouldShow - } - PromoId.BlackFriday -> { - val isActive = getBlackFridayPromoBanner()?.isActive ?: false + isActive && shouldShow + } + PromoId.BlackFriday -> { + val isActive = getBlackFridayPromoBanner()?.isActive == true - isActive && shouldShow + isActive && shouldShow + } } } - } } override fun isReadyToShowTokenPromo(promoId: PromoId): Flow { diff --git a/data/quotes/build.gradle.kts b/data/quotes/build.gradle.kts index 2549fee133..b4f0191e43 100644 --- a/data/quotes/build.gradle.kts +++ b/data/quotes/build.gradle.kts @@ -48,11 +48,8 @@ dependencies { // endregion // region Tests - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(projects.common.test) + testImplementation(projects.test.core) // endregion } \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt index 618f75178d..1660472cb9 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt @@ -66,8 +66,8 @@ internal class DefaultMultiQuoteStatusFetcher @Inject constructor( quotesStatusesStore.store(values = updatedResponse.quotes) } - .onLeft { - Timber.e(it) + .onLeft { throwable -> + Timber.e(throwable) quotesStatusesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) } diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/converter/QuoteStatusConverterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/converter/QuoteStatusConverterTest.kt index e6cb1e498c..f6653a4966 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/converter/QuoteStatusConverterTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/converter/QuoteStatusConverterTest.kt @@ -3,11 +3,11 @@ package com.tangem.data.quotes.converter import com.google.common.truth.Truth import com.tangem.common.test.data.quote.MockQuoteResponseFactory import com.tangem.common.test.data.quote.toDomain -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.api.tangemTech.models.QuotesResponse import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt index 958da45528..34c1f0af71 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt @@ -4,7 +4,6 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.common.test.utils.assertEither import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.data.quotes.store.setSourceAsCache @@ -15,6 +14,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.test.core.assertEither import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt index 9d47ec8857..ecb0adfbb3 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusUpdaterTest.kt @@ -2,11 +2,11 @@ package com.tangem.data.quotes.multi import arrow.core.right import com.google.common.truth.Truth -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.* diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/repository/DefaultQuotesRepositoryTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/repository/DefaultQuotesRepositoryTest.kt index 07c29dad8f..ee674d4e2a 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/repository/DefaultQuotesRepositoryTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/repository/DefaultQuotesRepositoryTest.kt @@ -1,12 +1,12 @@ package com.tangem.data.quotes.repository import com.google.common.truth.Truth -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.quotes.QuotesRepository +import com.tangem.test.core.ProvideTestModels import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.mockk diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducerTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducerTest.kt index ef5fccaf7f..0b8b3a47cd 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducerTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusProducerTest.kt @@ -1,12 +1,12 @@ package com.tangem.data.quotes.single import com.google.common.truth.Truth -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt index 93b3bb2077..583fd0c1ed 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreExtTest.kt @@ -4,11 +4,11 @@ import com.google.common.truth.Truth import com.tangem.common.test.data.quote.MockQuoteResponseFactory import com.tangem.common.test.data.quote.toDomain import com.tangem.common.test.datastore.MockStateDataStore -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.test.core.ProvideTestModels import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt index b9b3c6ee60..dd83f58209 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStatusesStoreTest.kt @@ -5,12 +5,12 @@ import com.google.common.truth.Truth import com.tangem.common.test.data.quote.MockQuoteResponseFactory import com.tangem.common.test.data.quote.toDomain import com.tangem.common.test.datastore.MockStateDataStore -import com.tangem.common.test.utils.ProvideTestModels -import com.tangem.common.test.utils.getEmittedValues import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.test.core.ProvideTestModels +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapterTest.kt index 3a4f3abb14..304b44cfe0 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapterTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/utils/QuotesUnsupportedCurrenciesIdAdapterTest.kt @@ -5,10 +5,10 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.quotes.utils.QuotesUnsupportedCurrenciesIdAdapter.ReplacementResult import com.tangem.datasource.api.tangemTech.models.QuotesResponse import com.tangem.datasource.api.tangemTech.models.QuotesResponse.Quote +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index 9c1b8171f0..dea79f4d3a 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -13,6 +13,7 @@ import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.usercountry.models.GB_COUNTRY import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -153,7 +154,7 @@ internal class DefaultSettingsRepository( } withContext(dispatchers.io) { - val country = runCatching { tangemTechApi.getUserCountryCode() } + val country = runSuspendCatching { tangemTechApi.getUserCountryCode() } .fold( onSuccess = GeoResponse::code, onFailure = { Locale.getDefault().country }, diff --git a/data/staking/build.gradle.kts b/data/staking/build.gradle.kts index 1ec5e094f8..a01398d354 100644 --- a/data/staking/build.gradle.kts +++ b/data/staking/build.gradle.kts @@ -69,11 +69,8 @@ dependencies { // endregion - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(tangemDeps.card.core) testImplementation(projects.common.test) + testImplementation(projects.test.core) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt new file mode 100644 index 0000000000..b97325f373 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -0,0 +1,264 @@ +package com.tangem.data.staking + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.data.staking.converters.ethpool.* +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.ethpool.P2PEthPoolApi +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest +import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingOption +import com.tangem.domain.staking.model.ethpool.* +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * P2P staking repository implementation + */ +internal class DefaultP2PEthPoolRepository( + private val p2pApi: P2PEthPoolApi, + private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, + private val dispatchers: CoroutineDispatcherProvider, +) : P2PEthPoolRepository { + + private val vaultConverter = P2PEthPoolVaultConverter + private val accountInfoConverter = P2PEthPoolAccountConverter + private val rewardConverter = P2PEthPoolRewardConverter + private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter + private val errorConverter = P2PEthPoolErrorConverter + + override suspend fun fetchVaults(network: P2PEthPoolNetwork) { + val vaults = getVaults(network).getOrElse { error -> + Timber.e("Error fetching P2P vaults: $error") + emptyList() + } + p2pEthPoolVaultsStore.store(vaults) + } + + override suspend fun getVaults(network: P2PEthPoolNetwork): Either> = either { + withContext(dispatchers.io) { + val response = p2pApi.getVaults(network.value) + when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + result.vaults.map { vaultConverter.convert(it) } + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + } + } + + override suspend fun createDepositTransaction( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + amount: String, + ): Either = either { + withContext(dispatchers.io) { + val requestBody = P2PEthPoolDepositRequest( + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")), + ) + val response = p2pApi.createDepositTransaction(network.value, requestBody) + when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + } + } + + override suspend fun createUnstakeTransaction( + network: P2PEthPoolNetwork, + stakerPublicKey: String, + stakeTransactionHash: String, + ): Either = either { + withContext(dispatchers.io) { + val requestBody = P2PEthPoolUnstakeRequest( + stakerPublicKey = stakerPublicKey, + stakeTransactionHash = stakeTransactionHash, + ) + val response = p2pApi.createUnstakeTransaction(network.value, requestBody) + when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + // Note: API returns only hex string for unstake, not full transaction structure + P2PEthPoolUnsignedTx( + serializeTx = result.unstakeTransactionHex, + to = "", // Will be parsed from hex by wallet + data = result.unstakeTransactionHex, + value = java.math.BigDecimal.ZERO, + nonce = 0, + chainId = network.chainId, + gasLimit = java.math.BigDecimal.ZERO, + maxFeePerGas = java.math.BigDecimal.ZERO, + maxPriorityFeePerGas = java.math.BigDecimal.ZERO, + ) + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + } + } + + override suspend fun createWithdrawTransaction( + network: P2PEthPoolNetwork, + stakerAddress: String, + ): Either = either { + withContext(dispatchers.io) { + val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress) + val response = p2pApi.createWithdrawTransaction(network.value, requestBody) + when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + } + } + + override suspend fun broadcastTransaction( + network: P2PEthPoolNetwork, + signedTransaction: String, + ): Either = either { + withContext(dispatchers.io) { + val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction) + val response = p2pApi.broadcastTransaction(network.value, requestBody) + when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + broadcastResultConverter.convert(result) + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + } + } + + override suspend fun getAccountInfo( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + ): Either = either { + withContext(dispatchers.io) { + val response = p2pApi.getAccountInfo(network.value, delegatorAddress, vaultAddress) + when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + accountInfoConverter.convert(result) + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + } + } + + override suspend fun getRewards( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + period: Int?, + ): Either> = either { + withContext(dispatchers.io) { + val response = p2pApi.getRewards( + network = network.value, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + period = period, + ) + when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + result.rewards.map { rewardConverter.convert(it) } + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + } + } + + override fun getStakingAvailability(): Flow { + return getVaultsFlow() + .distinctUntilChanged() + .map { vaults -> + if (vaults.isEmpty()) { + return@map StakingAvailability.TemporaryUnavailable + } + + val vault = findPublicVault(vaults = vaults) + + if (vault != null) { + StakingAvailability.Available(StakingOption.P2P(vault)) + } else { + StakingAvailability.TemporaryUnavailable + } + } + } + + override suspend fun getStakingAvailabilitySync(): StakingAvailability { + val vaults = getVaultsSync() + if (vaults.isEmpty()) { + return StakingAvailability.TemporaryUnavailable + } + + val vault = findPublicVault(vaults = vaults) + + return if (vault != null) { + StakingAvailability.Available(StakingOption.P2P(vault)) + } else { + StakingAvailability.TemporaryUnavailable + } + } + + private suspend fun getVaultsSync(): List { + return p2pEthPoolVaultsStore.getSync() + } + + private fun getVaultsFlow(): Flow> { + return p2pEthPoolVaultsStore.get() + } + + private fun findPublicVault(vaults: List): P2PEthPoolVault? { + return vaults.firstOrNull { vault -> !vault.isPrivate } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingActionRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitActionRepository.kt similarity index 87% rename from data/staking/src/main/java/com/tangem/data/staking/DefaultStakingActionRepository.kt rename to data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitActionRepository.kt index 030af40fb7..afc4dbef58 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingActionRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitActionRepository.kt @@ -4,15 +4,15 @@ import com.tangem.datasource.local.token.StakingActionsStore import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.stakekit.action.StakingAction -import com.tangem.domain.staking.repositories.StakingActionRepository +import com.tangem.domain.staking.repositories.StakeKitActionRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext -internal class DefaultStakingActionRepository( +internal class DefaultStakeKitActionRepository( private val stakingActionsStore: StakingActionsStore, private val dispatchers: CoroutineDispatcherProvider, -) : StakingActionRepository { +) : StakeKitActionRepository { override suspend fun store( userWalletId: UserWalletId, diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt new file mode 100644 index 0000000000..418992342d --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt @@ -0,0 +1,422 @@ +package com.tangem.data.staking + +import android.util.Base64 +import com.squareup.moshi.Moshi +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionStatus +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toCompressedPublicKey +import com.tangem.data.staking.converters.YieldConverter +import com.tangem.data.staking.converters.action.ActionStatusConverter +import com.tangem.data.staking.converters.action.EnterActionResponseConverter +import com.tangem.data.staking.converters.transaction.GasEstimateConverter +import com.tangem.data.staking.converters.transaction.StakingTransactionConverter +import com.tangem.data.staking.converters.transaction.StakingTransactionStatusConverter +import com.tangem.data.staking.converters.transaction.StakingTransactionTypeConverter +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.api.stakekit.models.request.* +import com.tangem.datasource.api.stakekit.models.response.EnabledYieldsResponse +import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO +import com.tangem.datasource.api.stakekit.models.response.model.transaction.tron.TronStakeKitTransaction +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.datasource.local.token.converter.StakingNetworkTypeConverter +import com.tangem.datasource.local.token.converter.YieldTokenConverter +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.staking.NetworkType +import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.StakingOption +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus +import com.tangem.domain.staking.model.stakekit.transaction.ActionParams +import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.repositories.StakeKitRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import timber.log.Timber + +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") +internal class DefaultStakeKitRepository( + private val stakeKitApi: StakeKitApi, + private val stakingYieldsStore: StakingYieldsStore, + private val dispatchers: CoroutineDispatcherProvider, + private val walletManagersFacade: WalletManagersFacade, + moshi: Moshi, +) : StakeKitRepository { + + private val transactionStatusConverter = StakingTransactionStatusConverter() + private val transactionTypeConverter = StakingTransactionTypeConverter() + private val actionStatusConverter = ActionStatusConverter() + + private val transactionConverter = StakingTransactionConverter( + transactionStatusConverter = transactionStatusConverter, + transactionTypeConverter = transactionTypeConverter, + ) + private val enterActionResponseConverter = EnterActionResponseConverter( + actionStatusConverter = actionStatusConverter, + transactionConverter = transactionConverter, + ) + + private val tronStakeKitTransactionAdapter by lazy { moshi.adapter(TronStakeKitTransaction::class.java) } + private val networkTypeAdapter by lazy { moshi.adapter(NetworkTypeDTO::class.java) } + private val stakingActionStatusAdapter by lazy { moshi.adapter(StakingActionStatusDTO::class.java) } + + override suspend fun fetchYields() { + withContext(dispatchers.io) { + val yieldsResponses = getAvailableStakeKitIntegrationsIds().map { + async { it.getYieldRequest() } + }.awaitAll() + + val yields = yieldsResponses.flatMap { response -> + when (response) { + is ApiResponse.Success -> response.data.data.filter { yield -> yield.isAvailable == true } + is ApiResponse.Error -> { + Timber.e("Error fetching enabled yields: ${response.cause}") + emptyList() + } + } + } + val shouldRewriteCache = yieldsResponses.all { it is ApiResponse.Success } + stakingYieldsStore.store(yields, shouldRewriteCache) + } + } + + override suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield { + return withContext(dispatchers.io) { + val rawCurrencyId = cryptoCurrencyId.rawCurrencyId ?: error("Staking custom tokens is not available") + + val prefetchedYield = findPrefetchedYield( + yields = getEnabledYieldsSync(), + currencyId = rawCurrencyId, + symbol = symbol, + ) + + prefetchedYield ?: error("Staking is unavailable") + } + } + + override suspend fun getYield(yieldId: String): Yield { + return withContext(dispatchers.io) { + getEnabledYieldsSync().find { it.id == yieldId } ?: error("Staking is unavailable") + } + } + + override suspend fun getActions( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + networkType: NetworkType, + stakingActionStatus: StakingActionStatus, + ): List { + return withContext(dispatchers.io) { + val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty() + + val networkTypeDto = StakingNetworkTypeConverter.convertBack(networkType) + val networkTypeString = networkTypeDto.extractJsonName() + + val actionStatusDTO = actionStatusConverter.convertBack(stakingActionStatus) + val actionStatusString = actionStatusDTO.extractJsonName() + + enterActionResponseConverter.convertListIgnoreErrors( + input = stakeKitApi.getActions( + walletAddress = address, + network = networkTypeString, + status = actionStatusString, + ).getOrThrow().data, + onError = { Timber.e("Error converting staking actions list: $it") }, + ) + } + } + + private suspend fun StakingIntegrationID.StakeKit.getYieldRequest(): ApiResponse { + return when (this) { + is StakingIntegrationID.StakeKit.Coin -> stakeKitApi.getEnabledYields( + preferredValidatorsOnly = false, + network = networkId, + ) + is StakingIntegrationID.StakeKit.EthereumToken -> stakeKitApi.getEnabledYields( + preferredValidatorsOnly = false, + yieldId = value, + network = networkId, + ) + } + } + + private fun getAvailableStakeKitIntegrationsIds(): List { + return StakingIntegrationID.StakeKit.entries + // load all integrations for now and filter in use cases if needed + // .filterNot { + // it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled + // } + } + + private fun NetworkTypeDTO.extractJsonName(): String { + return networkTypeAdapter.toJson(this).replace("\"", "") + } + + private fun StakingActionStatusDTO.extractJsonName(): String { + return stakingActionStatusAdapter.toJson(this).replace("\"", "") + } + + override suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo { + return withContext(dispatchers.io) { + val yield = getYield(cryptoCurrencyId, symbol) + + StakingEntryInfo( + tokenSymbol = yield.token.symbol, + ) + } + } + + override suspend fun createAction( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingAction { + return withContext(dispatchers.io) { + val response = when (params.actionCommonType) { + is StakingActionCommonType.Enter -> stakeKitApi.createEnterAction( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + is StakingActionCommonType.Exit -> stakeKitApi.createExitAction( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + is StakingActionCommonType.Pending -> stakeKitApi.createPendingAction( + createPendingActionRequestBody(params), + ) + } + + enterActionResponseConverter.convert(response.getOrThrow()) + } + } + + override suspend fun estimateGas( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingGasEstimate { + return withContext(dispatchers.io) { + val gasEstimateDTO = when (params.actionCommonType) { + is StakingActionCommonType.Enter -> stakeKitApi.estimateGasOnEnter( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + is StakingActionCommonType.Exit -> stakeKitApi.estimateGasOnExit( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + is StakingActionCommonType.Pending -> stakeKitApi.estimateGasOnPending( + createPendingActionRequestBody(params), + ) + } + + GasEstimateConverter.convert(gasEstimateDTO.getOrThrow()) + } + } + + override suspend fun constructTransaction( + networkId: String, + fee: Fee, + amount: Amount, + transactionId: String, + ): Pair { + return withContext(dispatchers.io) { + val transactionResponse = stakeKitApi.constructTransaction( + transactionId = transactionId, + body = ConstructTransactionRequestBody(), + ) + + val transaction = transactionConverter.convert(transactionResponse.getOrThrow()) + val unsignedTransaction = + transaction.unsignedTransaction ?: error("No unsigned transaction available") + val transactionData = TransactionData.Compiled( + value = getTransactionDataType(networkId, unsignedTransaction), + fee = fee, + amount = amount, + status = TransactionStatus.Unconfirmed, + ) + + transaction to transactionData + } + } + + private suspend fun createActionRequestBody( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): ActionRequestBody { + return ActionRequestBody( + integrationId = params.integrationId, + addresses = Address( + address = params.address, + additionalAddresses = createAdditionalAddresses(userWalletId, network, params), + ), + args = ActionRequestBodyArgs( + amount = params.amount.toPlainString(), + inputToken = YieldTokenConverter.convertBack(params.token), + validatorAddress = params.validatorAddress, + validatorAddresses = listOf(params.validatorAddress), // check on other networks + tronResource = getTronResource(network), + ), + ) + } + + private fun createPendingActionRequestBody(params: ActionParams): PendingActionRequestBody { + return PendingActionRequestBody( + integrationId = params.integrationId, + type = params.type ?: StakingActionType.UNKNOWN, + passthrough = params.passthrough.orEmpty(), + args = ActionRequestBodyArgs( + amount = params.amount.toPlainString(), + validatorAddress = params.validatorAddress, + validatorAddresses = listOf(params.validatorAddress), + ), + ) + } + + private suspend fun createAdditionalAddresses( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): Address.AdditionalAddresses? { + val selectedWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + return when (params.token.network) { + NetworkType.COSMOS -> Address.AdditionalAddresses( + cosmosPubKey = Base64.encodeToString( + /* input = */ selectedWallet?.wallet?.publicKey?.blockchainKey?.toCompressedPublicKey(), + /* flags = */ Base64.NO_WRAP, + ), + ) + else -> null + } + } + + private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data { + return when (Blockchain.fromId(networkId)) { + Blockchain.Solana, + Blockchain.Cosmos, + -> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes()) + Blockchain.BSC, + Blockchain.Ethereum, + Blockchain.TON, + Blockchain.Cardano, + -> TransactionData.Compiled.Data.RawString(unsignedTransaction) + Blockchain.Tron -> { + val tronStakeKitTransaction = tronStakeKitTransactionAdapter.fromJson(unsignedTransaction) + ?: error("Failed to parse Tron StakeKit transaction") + TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex) + } + else -> error("Unsupported blockchain") + } + } + + private fun findPrefetchedYield(yields: List, currencyId: CryptoCurrency.RawID, symbol: String): Yield? { + return yields.find { yield -> + yield.tokens.any { it.coinGeckoId == currencyId.value && it.symbol == symbol } + } + } + + private suspend fun getEnabledYieldsSync(): List { + return YieldConverter.convertListIgnoreErrors( + input = stakingYieldsStore.getSync(), + onError = { Timber.e("Error converting one of the items in enabled yields: $it") }, + ) + } + + override fun getEnabledYields(): Flow> { + return stakingYieldsStore.get().map { yields -> + YieldConverter.convertListIgnoreErrors( + input = yields, + onError = { Timber.e("Error converting one of the items in enabled yields: $it") }, + ) + } + } + + override fun getStakingAvailability( + rawCurrencyId: CryptoCurrency.RawID, + symbol: String, + ): Flow { + return getEnabledYields() + .distinctUntilChanged() + .map { yields -> + if (yields.isEmpty()) { + return@map StakingAvailability.TemporaryUnavailable + } + + val prefetchedYield = findPrefetchedYield( + yields = yields, + currencyId = rawCurrencyId, + symbol = symbol, + ) + + if (prefetchedYield != null) { + StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield)) + } else { + StakingAvailability.TemporaryUnavailable + } + } + } + + override suspend fun getStakingAvailabilitySync( + rawCurrencyId: CryptoCurrency.RawID, + symbol: String, + ): StakingAvailability { + val yields = getEnabledYieldsSync() + if (yields.isEmpty()) { + return StakingAvailability.TemporaryUnavailable + } + + val prefetchedYield = findPrefetchedYield( + yields = yields, + currencyId = rawCurrencyId, + symbol = symbol, + ) + + return if (prefetchedYield != null) { + StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield)) + } else { + StakingAvailability.TemporaryUnavailable + } + } + + private fun getTronResource(network: Network): TronResource? { + val blockchain = Blockchain.fromNetworkId(network.backendId) + + return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) { + TronResource.ENERGY + } else { + null + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingTransactionHashRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitTransactionHashRepository.kt similarity index 95% rename from data/staking/src/main/java/com/tangem/data/staking/DefaultStakingTransactionHashRepository.kt rename to data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitTransactionHashRepository.kt index 0d12489648..0147b2da07 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingTransactionHashRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitTransactionHashRepository.kt @@ -6,16 +6,16 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectListSync import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata -import com.tangem.domain.staking.repositories.StakingTransactionHashRepository +import com.tangem.domain.staking.repositories.StakeKitTransactionHashRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import timber.log.Timber -internal class DefaultStakingTransactionHashRepository( +internal class DefaultStakeKitTransactionHashRepository( private val stakeKitApi: StakeKitApi, private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, -) : StakingTransactionHashRepository { +) : StakeKitTransactionHashRepository { override suspend fun submitHash(transactionId: String, transactionHash: String) { withContext(dispatchers.io) { diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt index a5f5c43f33..99dbdf2927 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt @@ -29,6 +29,13 @@ internal class DefaultStakingErrorResolver( is StakingError.DomainError -> { analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error)) } + // P2P errors + is StakingError.InvalidAmount, + is StakingError.DataError, + is StakingError.UnknownError, + -> { + // P2P errors - no specific analytics event yet + } } return error diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index e055e89e36..ee0a73cf7b 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -1,55 +1,19 @@ package com.tangem.data.staking -import android.util.Base64 import arrow.core.getOrElse -import com.squareup.moshi.Moshi -import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionStatus -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.common.extensions.hexToBytes -import com.tangem.common.extensions.toCompressedPublicKey -import com.tangem.data.staking.converters.YieldConverter -import com.tangem.data.staking.converters.action.ActionStatusConverter -import com.tangem.data.staking.converters.action.EnterActionResponseConverter -import com.tangem.data.staking.converters.transaction.GasEstimateConverter -import com.tangem.data.staking.converters.transaction.StakingTransactionConverter -import com.tangem.data.staking.converters.transaction.StakingTransactionStatusConverter -import com.tangem.data.staking.converters.transaction.StakingTransactionTypeConverter import com.tangem.data.staking.store.YieldsBalancesStore -import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.stakekit.StakeKitApi -import com.tangem.datasource.api.stakekit.models.request.* -import com.tangem.datasource.api.stakekit.models.response.EnabledYieldsResponse -import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO -import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO -import com.tangem.datasource.api.stakekit.models.response.model.transaction.tron.TronStakeKitTransaction -import com.tangem.datasource.local.token.StakingYieldsStore -import com.tangem.datasource.local.token.converter.StakingNetworkTypeConverter -import com.tangem.datasource.local.token.converter.YieldTokenConverter import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.staking.YieldBalance -import com.tangem.domain.models.staking.action.StakingActionType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability -import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.staking.model.StakingIntegrationID -import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.model.stakekit.action.StakingAction -import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType -import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus -import com.tangem.domain.staking.model.stakekit.transaction.ActionParams -import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate -import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.repositories.StakeKitRepository import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade @@ -57,155 +21,22 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.lib.crypto.BlockchainUtils.isCardano import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.extensions.orZero -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.withContext -import timber.log.Timber @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class DefaultStakingRepository( - private val stakeKitApi: StakeKitApi, - private val stakingYieldsStore: StakingYieldsStore, + private val stakeKitRepository: StakeKitRepository, + private val p2pEthPoolRepository: P2PEthPoolRepository, private val stakingBalanceStoreV2: YieldsBalancesStore, private val dispatchers: CoroutineDispatcherProvider, - private val walletManagersFacade: WalletManagersFacade, private val getUserWalletUseCase: GetUserWalletUseCase, private val stakingFeatureToggles: StakingFeatureToggles, - moshi: Moshi, + private val walletManagersFacade: WalletManagersFacade, ) : StakingRepository { - private val transactionStatusConverter = StakingTransactionStatusConverter() - private val transactionTypeConverter = StakingTransactionTypeConverter() - private val actionStatusConverter = ActionStatusConverter() - - private val transactionConverter = StakingTransactionConverter( - transactionStatusConverter = transactionStatusConverter, - transactionTypeConverter = transactionTypeConverter, - ) - private val enterActionResponseConverter = EnterActionResponseConverter( - actionStatusConverter = actionStatusConverter, - transactionConverter = transactionConverter, - ) - - private val tronStakeKitTransactionAdapter by lazy { moshi.adapter(TronStakeKitTransaction::class.java) } - private val networkTypeAdapter by lazy { moshi.adapter(NetworkTypeDTO::class.java) } - private val stakingActionStatusAdapter by lazy { moshi.adapter(StakingActionStatusDTO::class.java) } - - override suspend fun fetchEnabledYields() { - withContext(dispatchers.io) { - val yieldsResponses = getAvailableIntegrationsIds().map { - async { it.getYieldRequest() } - }.awaitAll() - - val yields = yieldsResponses.flatMap { response -> - when (response) { - is ApiResponse.Success -> response.data.data.filter { yield -> yield.isAvailable == true } - else -> { - Timber.e("Error fetching enabled yields: ${(response as? ApiResponse.Error)?.cause}") - emptyList() - } - } - } - - val shouldRewriteCache = yieldsResponses.all { it is ApiResponse.Success } - stakingYieldsStore.store(yields, shouldRewriteCache) - } - } - - override suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield { - return withContext(dispatchers.io) { - val rawCurrencyId = cryptoCurrencyId.rawCurrencyId ?: error("Staking custom tokens is not available") - - val prefetchedYield = findPrefetchedYield( - yields = getEnabledYieldsSync(), - currencyId = rawCurrencyId, - symbol = symbol, - ) - - prefetchedYield ?: error("Staking is unavailable") - } - } - - override suspend fun getYield(yieldId: String): Yield { - return withContext(dispatchers.io) { - getEnabledYieldsSync().find { it.id == yieldId } ?: error("Staking is unavailable") - } - } - - override suspend fun getActions( - userWalletId: UserWalletId, - cryptoCurrency: CryptoCurrency, - networkType: NetworkType, - stakingActionStatus: StakingActionStatus, - ): List { - return withContext(dispatchers.io) { - val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty() - - val networkTypeDto = StakingNetworkTypeConverter.convertBack(networkType) - val networkTypeString = networkTypeDto.extractJsonName() - - val actionStatusDTO = actionStatusConverter.convertBack(stakingActionStatus) - val actionStatusString = actionStatusDTO.extractJsonName() - - enterActionResponseConverter.convertListIgnoreErrors( - input = stakeKitApi.getActions( - walletAddress = address, - network = networkTypeString, - status = actionStatusString, - ).getOrThrow().data, - onError = { Timber.e("Error converting staking actions list: $it") }, - ) - } - } - - private suspend fun StakingIntegrationID.getYieldRequest(): ApiResponse { - return when (this) { - is StakingIntegrationID.Coin -> stakeKitApi.getEnabledYields( - preferredValidatorsOnly = false, - network = networkId, - ) - is StakingIntegrationID.EthereumToken -> stakeKitApi.getEnabledYields( - preferredValidatorsOnly = false, - yieldId = value, - network = networkId, - ) - } - } - - private fun getAvailableIntegrationsIds(): List { - return StakingIntegrationID.entries - // load all integrations for now and filter in use cases if needed - // .filterNot { - // it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled - // } - } - - private fun NetworkTypeDTO.extractJsonName(): String { - return networkTypeAdapter.toJson(this).replace("\"", "") - } - - private fun StakingActionStatusDTO.extractJsonName(): String { - return stakingActionStatusAdapter.toJson(this).replace("\"", "") - } - - override suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo { - return withContext(dispatchers.io) { - val yield = getYield(cryptoCurrencyId, symbol) - - StakingEntryInfo( - rewardInfo = requireNotNull( - yield - .preferredValidators - .maxByOrNull { it.rewardInfo?.rate.orZero() }?.rewardInfo, - ), - rewardSchedule = yield.metadata.rewardSchedule, - tokenSymbol = yield.token.symbol, - ) - } - } - override fun getStakingAvailability( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, @@ -227,32 +58,18 @@ internal class DefaultStakingRepository( return@channelFlow } - val isSupportedInMobileApp = StakingIntegrationID.create(currencyId = cryptoCurrency.id) != null + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) - getEnabledYields() - .distinctUntilChanged() - .onEach { yields -> - if (yields.isEmpty()) { - send(StakingAvailability.TemporaryUnavailable) - return@onEach - } + val availabilityFlow = when (stakingIntegration) { + is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailability() + is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability( + rawCurrencyId, + cryptoCurrency.symbol, + ) + null -> flowOf(StakingAvailability.Unavailable) + } - val prefetchedYield = findPrefetchedYield( - yields = yields, - currencyId = rawCurrencyId, - symbol = cryptoCurrency.symbol, - ) - when { - prefetchedYield != null && isSupportedInMobileApp -> { - send(StakingAvailability.Available(prefetchedYield)) - } - prefetchedYield == null && isSupportedInMobileApp -> { - send(StakingAvailability.TemporaryUnavailable) - } - else -> send(StakingAvailability.Unavailable) - } - } - .launchIn(this) + availabilityFlow.collect { send(it) } } } @@ -273,33 +90,41 @@ internal class DefaultStakingRepository( return StakingAvailability.Unavailable } - val isSupportedInMobileApp = StakingIntegrationID.create(currencyId = cryptoCurrency.id) != null + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) + ?: return StakingAvailability.Unavailable - val yields = getEnabledYieldsSync() - if (yields.isEmpty()) { - return StakingAvailability.TemporaryUnavailable + return when (stakingIntegration) { + is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailabilitySync() + is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailabilitySync( + rawCurrencyId, + cryptoCurrency.symbol, + ) } + } - val prefetchedYield = findPrefetchedYield( - yields = yields, - currencyId = rawCurrencyId, - symbol = cryptoCurrency.symbol, - ) + override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { + return withContext(dispatchers.default) { + val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false - return when { - prefetchedYield != null && isSupportedInMobileApp -> { - StakingAvailability.Available(prefetchedYield) + val hasDataYieldBalance by lazy { + balances.any { yieldBalance -> + (yieldBalance as? YieldBalance.Data)?.balance?.items?.isNotEmpty() == true + } } - prefetchedYield == null && isSupportedInMobileApp -> { - StakingAvailability.TemporaryUnavailable - } - else -> StakingAvailability.Unavailable + + balances.isNotEmpty() && hasDataYieldBalance } } private suspend fun checkFeatureToggleEnabled(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { return when (cryptoCurrency.network.id.toBlockchain()) { Blockchain.TON -> stakingFeatureToggles.isTonStakingEnabled + Blockchain.Ethereum -> { + when (cryptoCurrency) { + is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled + is CryptoCurrency.Token -> true + } + } Blockchain.Cardano -> { val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty() val balance = stakingBalanceStoreV2.getSyncOrNull( @@ -337,208 +162,6 @@ internal class DefaultStakingRepository( } } - override suspend fun createAction( - userWalletId: UserWalletId, - network: Network, - params: ActionParams, - ): StakingAction { - return withContext(dispatchers.io) { - val response = when (params.actionCommonType) { - is StakingActionCommonType.Enter -> stakeKitApi.createEnterAction( - createActionRequestBody( - userWalletId, - network, - params, - ), - ) - is StakingActionCommonType.Exit -> stakeKitApi.createExitAction( - createActionRequestBody( - userWalletId, - network, - params, - ), - ) - is StakingActionCommonType.Pending -> stakeKitApi.createPendingAction( - createPendingActionRequestBody(params), - ) - } - - enterActionResponseConverter.convert(response.getOrThrow()) - } - } - - override suspend fun estimateGas( - userWalletId: UserWalletId, - network: Network, - params: ActionParams, - ): StakingGasEstimate { - return withContext(dispatchers.io) { - val gasEstimateDTO = when (params.actionCommonType) { - is StakingActionCommonType.Enter -> stakeKitApi.estimateGasOnEnter( - createActionRequestBody( - userWalletId, - network, - params, - ), - ) - is StakingActionCommonType.Exit -> stakeKitApi.estimateGasOnExit( - createActionRequestBody( - userWalletId, - network, - params, - ), - ) - is StakingActionCommonType.Pending -> stakeKitApi.estimateGasOnPending( - createPendingActionRequestBody(params), - ) - } - - GasEstimateConverter.convert(gasEstimateDTO.getOrThrow()) - } - } - - override suspend fun constructTransaction( - networkId: String, - fee: Fee, - amount: Amount, - transactionId: String, - ): Pair { - return withContext(dispatchers.io) { - val transactionResponse = stakeKitApi.constructTransaction( - transactionId = transactionId, - body = ConstructTransactionRequestBody(), - ) - - val transaction = transactionConverter.convert(transactionResponse.getOrThrow()) - val unsignedTransaction = - transaction.unsignedTransaction ?: error("No unsigned transaction available") - val transactionData = TransactionData.Compiled( - value = getTransactionDataType(networkId, unsignedTransaction), - fee = fee, - amount = amount, - status = TransactionStatus.Unconfirmed, - ) - - transaction to transactionData - } - } - - override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { - return withContext(dispatchers.default) { - val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false - - val hasDataYieldBalance by lazy { - balances.any { yieldBalance -> - (yieldBalance as? YieldBalance.Data)?.balance?.items?.isNotEmpty() == true - } - } - - balances.isNotEmpty() && hasDataYieldBalance - } - } - - private suspend fun createActionRequestBody( - userWalletId: UserWalletId, - network: Network, - params: ActionParams, - ): ActionRequestBody { - return ActionRequestBody( - integrationId = params.integrationId, - addresses = Address( - address = params.address, - additionalAddresses = createAdditionalAddresses(userWalletId, network, params), - ), - args = ActionRequestBodyArgs( - amount = params.amount.toPlainString(), - inputToken = YieldTokenConverter.convertBack(params.token), - validatorAddress = params.validatorAddress, - validatorAddresses = listOf(params.validatorAddress), // check on other networks - tronResource = getTronResource(network), - ), - ) - } - - private fun createPendingActionRequestBody(params: ActionParams): PendingActionRequestBody { - return PendingActionRequestBody( - integrationId = params.integrationId, - type = params.type ?: StakingActionType.UNKNOWN, - passthrough = params.passthrough.orEmpty(), - args = ActionRequestBodyArgs( - amount = params.amount.toPlainString(), - validatorAddress = params.validatorAddress, - validatorAddresses = listOf(params.validatorAddress), - ), - ) - } - - private suspend fun createAdditionalAddresses( - userWalletId: UserWalletId, - network: Network, - params: ActionParams, - ): Address.AdditionalAddresses? { - val selectedWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) - return when (params.token.network) { - NetworkType.COSMOS -> Address.AdditionalAddresses( - cosmosPubKey = Base64.encodeToString( - /* input = */ selectedWallet?.wallet?.publicKey?.blockchainKey?.toCompressedPublicKey(), - /* flags = */ Base64.NO_WRAP, - ), - ) - else -> null - } - } - - private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data { - return when (Blockchain.fromId(networkId)) { - Blockchain.Solana, - Blockchain.Cosmos, - -> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes()) - Blockchain.BSC, - Blockchain.Ethereum, - Blockchain.TON, - Blockchain.Cardano, - -> TransactionData.Compiled.Data.RawString(unsignedTransaction) - Blockchain.Tron -> { - val tronStakeKitTransaction = tronStakeKitTransactionAdapter.fromJson(unsignedTransaction) - ?: error("Failed to parse Tron StakeKit transaction") - TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex) - } - else -> error("Unsupported blockchain") - } - } - - private fun findPrefetchedYield(yields: List, currencyId: CryptoCurrency.RawID, symbol: String): Yield? { - return yields.find { yield -> - yield.tokens.any { it.coinGeckoId == currencyId.value && it.symbol == symbol } - } - } - - private suspend fun getEnabledYieldsSync(): List { - return YieldConverter.convertListIgnoreErrors( - input = stakingYieldsStore.getSync(), - onError = { Timber.e("Error converting one of the items in enabled yields: $it") }, - ) - } - - override fun getEnabledYields(): Flow> { - return stakingYieldsStore.get().map { - YieldConverter.convertListIgnoreErrors( - input = it, - onError = { Timber.e("Error converting one of the items in enabled yields: $it") }, - ) - } - } - - private fun getTronResource(network: Network): TronResource? { - val blockchain = Blockchain.fromNetworkId(network.backendId) - - return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) { - TronResource.ENERGY - } else { - null - } - } - companion object { private val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79") } diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt new file mode 100644 index 0000000000..a19708587c --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt @@ -0,0 +1,50 @@ +package com.tangem.data.staking.converters.ethpool + +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO +import com.tangem.domain.staking.model.ethpool.* +import com.tangem.utils.converter.Converter +import org.joda.time.Instant + +/** + * Converter from P2P Account Info Response to Domain model + */ +internal object P2PEthPoolAccountConverter : Converter { + + override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount { + return P2PEthPoolAccount( + delegatorAddress = value.delegatorAddress, + vaultAddress = value.vaultAddress, + stake = convertStake(value.stake), + availableToUnstake = value.availableToUnstake, + availableToWithdraw = value.availableToWithdraw, + exitQueue = convertExitQueue(value.exitQueue), + ) + } + + private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake { + return P2PEthPoolStake( + assets = dto.assets, + totalEarnedAssets = dto.totalEarnedAssets, + ) + } + + private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue { + return P2PEthPoolExitQueue( + total = dto.total.toBigDecimal(), + requests = dto.requests.map(::convertExitRequest), + ) + } + + private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest { + return P2PEthPoolExitRequest( + ticket = dto.ticket, + totalAssets = dto.totalAssets.toBigDecimal(), + timestamp = Instant.ofEpochSecond(dto.timestamp), + withdrawalTimestamp = Instant.ofEpochSecond(dto.withdrawalTimestamp), + isClaimable = dto.isClaimable, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt new file mode 100644 index 0000000000..6f85db1475 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt @@ -0,0 +1,35 @@ +package com.tangem.data.staking.converters.ethpool + +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolBroadcastResponse +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTxStatusDTO +import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult +import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastStatus +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +/** + * Converter from P2P Broadcast Transaction Response to Domain model + */ +internal object P2PEthPoolBroadcastResultConverter : Converter { + + override fun convert(value: P2PEthPoolBroadcastResponse): P2PEthPoolBroadcastResult { + return P2PEthPoolBroadcastResult( + hash = value.hash, + status = convertStatus(value.status), + blockNumber = value.blockNumber, + transactionIndex = value.transactionIndex, + gasUsed = value.gasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO, + cumulativeGasUsed = value.cumulativeGasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO, + effectiveGasPrice = value.effectiveGasPrice?.toBigDecimalOrNull(), + from = value.from, + to = value.to, + ) + } + + private fun convertStatus(status: P2PEthPoolTxStatusDTO): P2PEthPoolBroadcastStatus { + return when (status) { + P2PEthPoolTxStatusDTO.SUCCESS -> P2PEthPoolBroadcastStatus.SUCCESS + P2PEthPoolTxStatusDTO.FAILED -> P2PEthPoolBroadcastStatus.FAILED + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt new file mode 100644 index 0000000000..baf0e6a718 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt @@ -0,0 +1,69 @@ +package com.tangem.data.staking.converters.ethpool + +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolErrorDetailsDTO +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolErrorResponse +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.utils.converter.Converter + +/** + * Converter from P2P Error Response to Domain StakingError + */ +@Suppress("MagicNumber") +internal object P2PEthPoolErrorConverter : Converter { + + override fun convert(value: P2PEthPoolErrorResponse): StakingError { + return convertFromErrorDetails(value.error) + } + + fun convertFromErrorDetails(details: P2PEthPoolErrorDetailsDTO): StakingError { + return when (details.code) { + // Authentication errors + 101111 -> StakingError.UnknownError( + Exception("Missing Bearer token: ${details.message}"), + ) + 101109 -> StakingError.UnknownError( + Exception("Invalid Bearer token: ${details.message}"), + ) + 101110 -> StakingError.UnknownError( + Exception("Server authorization error: ${details.message}"), + ) + + // Validation errors + 100101 -> StakingError.InvalidAmount(details.message) + + // Withdrawal errors + 127104 -> StakingError.DataError( + IllegalStateException("No withdrawable balance: ${details.message}"), + ) + 127105 -> StakingError.UnknownError( + Exception("Gas amount too low: ${details.message}"), + ) + 127106 -> StakingError.InvalidAmount("Invalid delegator address: ${details.message}") + 127107 -> StakingError.UnknownError( + Exception("Gas price too low: ${details.message}"), + ) + 127108 -> StakingError.UnknownError( + Exception("Transaction simulation failed: ${details.message}"), + ) + + // Vault errors + 127101 -> StakingError.DataError( + IllegalStateException("Invalid vault: ${details.message}"), + ) + + // Account errors + 124108 -> StakingError.DataError( + IllegalStateException("Invalid delegator: ${details.message}"), + ) + + // Network errors + 127100 -> StakingError.DataError( + IllegalStateException("Unsupported network: ${details.message}"), + ) + + else -> StakingError.UnknownError( + Exception("${details.code}: ${details.message}"), + ) + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt new file mode 100644 index 0000000000..da6432d026 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt @@ -0,0 +1,20 @@ +package com.tangem.data.staking.converters.ethpool + +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolRewardDTO +import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward +import com.tangem.utils.converter.Converter + +/** + * Converter from P2P Reward Entry DTO to Domain model + */ +internal object P2PEthPoolRewardConverter : Converter { + + override fun convert(value: P2PEthPoolRewardDTO): P2PEthPoolReward { + return P2PEthPoolReward( + date = value.date, + apy = value.apy.toBigDecimal(), + balance = value.balance, + rewards = value.rewards, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt new file mode 100644 index 0000000000..98c7f7caf7 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt @@ -0,0 +1,26 @@ +package com.tangem.data.staking.converters.ethpool + +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolUnsignedTxDTO +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +/** + * Converter from P2P Unsigned Transaction DTO to Domain model + */ +internal object P2PEthPoolUnsignedTxConverter : Converter { + + override fun convert(value: P2PEthPoolUnsignedTxDTO): P2PEthPoolUnsignedTx { + return P2PEthPoolUnsignedTx( + serializeTx = value.serializeTx, + to = value.to, + data = value.data, + value = value.value.toBigDecimalOrNull() ?: BigDecimal.ZERO, + nonce = value.nonce, + chainId = value.chainId, + gasLimit = value.gasLimit, + maxFeePerGas = value.maxFeePerGas, + maxPriorityFeePerGas = value.maxPriorityFeePerGas, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt new file mode 100644 index 0000000000..cc010e8faa --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.data.staking.converters.ethpool + +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.utils.converter.Converter + +/** + * Converter from P2P Vault DTO to Domain model + */ +internal object P2PEthPoolVaultConverter : Converter { + + override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault { + return P2PEthPoolVault( + vaultAddress = value.vaultAddress, + displayName = value.displayName, + apy = value.apy.toBigDecimal(), + baseApy = value.baseApy.toBigDecimal(), + capacity = value.capacity.toBigDecimal(), + totalAssets = value.totalAssets.toBigDecimal(), + feePercent = value.feePercent.toBigDecimal(), + isPrivate = value.isPrivate, + isGenesis = value.isGenesis, + isSmoothingPool = value.isSmoothingPool, + isErc20 = value.isErc20, + tokenName = value.tokenName, + tokenSymbol = value.tokenSymbol, + createdAt = value.createdAt, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index d96e876a73..92c1ecf98d 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -3,24 +3,20 @@ package com.tangem.data.staking.di import com.squareup.moshi.Moshi import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.data.staking.DefaultStakingActionRepository -import com.tangem.data.staking.DefaultStakingErrorResolver -import com.tangem.data.staking.DefaultStakingRepository -import com.tangem.data.staking.DefaultStakingTransactionHashRepository +import com.tangem.data.staking.* import com.tangem.data.staking.converters.error.StakeKitErrorConverter import com.tangem.data.staking.store.YieldsBalancesStore import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles import com.tangem.data.staking.utils.DefaultStakingCleaner +import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.StakingActionsStore import com.tangem.datasource.local.token.StakingYieldsStore -import com.tangem.domain.staking.repositories.StakingActionRepository -import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.staking.repositories.StakingTransactionHashRepository +import com.tangem.domain.staking.repositories.* import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.domain.walletmanager.WalletManagersFacade @@ -38,36 +34,66 @@ internal object StakingDataModule { @Provides @Singleton - fun provideStakingRepository( + fun provideStakeKitRepository( stakeKitApi: StakeKitApi, stakingYieldsStore: StakingYieldsStore, - yieldsBalancesStore: YieldsBalancesStore, dispatchers: CoroutineDispatcherProvider, walletManagersFacade: WalletManagersFacade, - getUserWalletUseCase: GetUserWalletUseCase, - stakingFeatureToggles: StakingFeatureToggles, @NetworkMoshi moshi: Moshi, - ): StakingRepository { - return DefaultStakingRepository( + ): StakeKitRepository { + return DefaultStakeKitRepository( stakeKitApi = stakeKitApi, stakingYieldsStore = stakingYieldsStore, - stakingBalanceStoreV2 = yieldsBalancesStore, dispatchers = dispatchers, walletManagersFacade = walletManagersFacade, - getUserWalletUseCase = getUserWalletUseCase, - stakingFeatureToggles = stakingFeatureToggles, moshi = moshi, ) } + @Provides + @Singleton + fun provideStakingRepository( + stakeKitRepository: StakeKitRepository, + p2pEthPoolRepository: P2PEthPoolRepository, + yieldsBalancesStore: YieldsBalancesStore, + dispatchers: CoroutineDispatcherProvider, + getUserWalletUseCase: GetUserWalletUseCase, + stakingFeatureToggles: StakingFeatureToggles, + walletManagersFacade: WalletManagersFacade, + ): StakingRepository { + return DefaultStakingRepository( + stakeKitRepository = stakeKitRepository, + p2pEthPoolRepository = p2pEthPoolRepository, + stakingBalanceStoreV2 = yieldsBalancesStore, + dispatchers = dispatchers, + getUserWalletUseCase = getUserWalletUseCase, + walletManagersFacade = walletManagersFacade, + stakingFeatureToggles = stakingFeatureToggles, + ) + } + + @Provides + @Singleton + fun provideP2PEthPoolRepository( + p2pApi: P2PEthPoolApi, + p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, + dispatchers: CoroutineDispatcherProvider, + ): P2PEthPoolRepository { + return DefaultP2PEthPoolRepository( + p2pApi = p2pApi, + p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, + dispatchers = dispatchers, + ) + } + @Provides @Singleton fun provideStakingTransactionHashRepository( stakeKitApi: StakeKitApi, appPreferencesStore: AppPreferencesStore, dispatchers: CoroutineDispatcherProvider, - ): StakingTransactionHashRepository { - return DefaultStakingTransactionHashRepository( + ): StakeKitTransactionHashRepository { + return DefaultStakeKitTransactionHashRepository( stakeKitApi = stakeKitApi, appPreferencesStore = appPreferencesStore, dispatchers = dispatchers, @@ -79,8 +105,8 @@ internal object StakingDataModule { fun provideStakingActionRepository( stakingActionsStore: StakingActionsStore, dispatchers: CoroutineDispatcherProvider, - ): StakingActionRepository { - return DefaultStakingActionRepository( + ): StakeKitActionRepository { + return DefaultStakeKitActionRepository( stakingActionsStore = stakingActionsStore, dispatchers = dispatchers, ) diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt index 62f2e5e33e..eca2f69f33 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt @@ -69,8 +69,8 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor( fetch(userWalletId = params.userWalletId, stakingIds = availableStakingIds) } - .onLeft { - Timber.e(it, "Unable to fetch yield balances $params") + .onLeft { throwable -> + Timber.e(throwable, "Unable to fetch yield balances $params") yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) } @@ -163,21 +163,21 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor( if (!allResponsesReceived(requests, yieldBalances)) { val values = stakingIds.filter { stakingId -> - yieldBalances.none { - stakingId.integrationId == it.integrationId && - stakingId.address == it.addresses.address + yieldBalances.none { balanceWrapper -> + stakingId.integrationId == balanceWrapper.integrationId && + stakingId.address == balanceWrapper.addresses.address } } yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet()) } }, - onError = { - Timber.e(it, "Unable to fetch yield balances $userWalletId") + onError = { throwable -> + Timber.e(throwable, "Unable to fetch yield balances $userWalletId") yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) - throw it + throw throwable }, ) } @@ -187,9 +187,9 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor( yieldBalances: Set, ): Boolean { return requests.all { request -> - yieldBalances.any { - request.integrationId == it.integrationId && - request.addresses.address == it.addresses.address + yieldBalances.any { balance -> + request.integrationId == balance.integrationId && + request.addresses.address == balance.addresses.address } } } diff --git a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt index a0c9e1640c..e5ee6c623a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt @@ -12,4 +12,7 @@ internal class DefaultStakingFeatureToggles( override val isCardanoStakingEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled("STAKING_CARDANO_ENABLED") + + override val isEthStakingEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("STAKING_ETH_ENABLED") } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt index 8da94e5450..4db8d77f02 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt @@ -14,7 +14,7 @@ internal object YieldBalanceRequestBodyFactory { return YieldBalanceRequestBody( addresses = YieldBalanceRequestBodyAddressFactory.create(stakingID), args = YieldBalanceRequestBody.YieldBalanceRequestArgs( - validatorAddresses = listOf(), // todo add validators [REDACTED_JIRA] + validatorAddresses = emptyList(), // todo add validators [REDACTED_JIRA] ), integrationId = stakingID.integrationId, ) diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt index 1ae2b3fccd..f7c56f3dc3 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt @@ -4,8 +4,6 @@ import arrow.core.toOption import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.data.staking.MockYieldDTOFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.common.test.utils.assertEitherLeft -import com.tangem.common.test.utils.assertEitherRight import com.tangem.data.staking.store.YieldsBalancesStore import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.datasource.api.common.response.ApiResponse @@ -17,6 +15,8 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.test.core.assertEitherLeft +import com.tangem.test.core.assertEitherRight import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt index 8445b62e3e..08fada9524 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt @@ -2,13 +2,13 @@ package com.tangem.data.staking.multi import com.google.common.truth.Truth import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.staking.store.YieldsBalancesStore import com.tangem.data.staking.toDomain import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt index cef37862e0..aa9e56d44a 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt @@ -2,7 +2,6 @@ package com.tangem.data.staking.single import com.google.common.truth.Truth import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory -import com.tangem.common.test.utils.getEmittedValues import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.data.staking.toDomain import com.tangem.domain.models.staking.StakingID @@ -11,6 +10,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.multi.MultiYieldBalanceProducer import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.every diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt index 8227216a40..09366ecbcf 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt @@ -3,11 +3,11 @@ package com.tangem.data.staking.store import com.google.common.truth.Truth import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.datastore.MockStateDataStore -import com.tangem.common.test.utils.getEmittedValues import com.tangem.data.staking.toDomain import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt index 08dc91f9d6..32751b6fc6 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt @@ -23,7 +23,7 @@ class DefaultStakingCleanerTest { ) private val userWalletId = UserWalletId("011") private val stakingIds = setOf( - StakingID(integrationId = StakingIntegrationID.Coin.Cardano.value, address = "0x1"), + StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"), ) @BeforeEach 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/detekt-baseline-debug.xml b/data/swap/detekt-baseline-debug.xml new file mode 100644 index 0000000000..b047ac0eff --- /dev/null +++ b/data/swap/detekt-baseline-debug.xml @@ -0,0 +1,19 @@ + + + + + MaxChainedCallsOnSameLine:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2$fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty() + MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ Timber.w(it, "Unable to get pairs") throw it } + MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.currency.getContractAddress() == pair.from.contractAddress && it.currency.network.backendId == pair.from.network } + MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.currency.getContractAddress() == pair.to.contractAddress && it.currency.network.backendId == pair.to.network } + MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.getContractAddress() == pair.from.contractAddress && it.network.backendId == pair.from.network } + MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.getContractAddress() == pair.to.contractAddress && it.network.backendId == pair.to.network } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.checkId( checkUserWalletId = userWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.userWalletId == userWallet.walletId.stringValue && ( it.toCryptoCurrencyId == cryptoCurrencyId.value || it.fromCryptoCurrencyId == cryptoCurrencyId.value ) } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ listConverter.convertBack( value = it, multiAccountList = multiAccountList, userWallet = userWallet, txStatuses = txStatuses, ) } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ storeTransactionState( txId = transaction.txId, status = it, accountWithCurrency = fromAccount?.accountId to fromCryptoCurrency, ) } + MultilineLambdaItParameter:SwapDataConverter.kt$SwapDataConverter${ if (it == "0") { BigDecimal.ZERO } else { requireNotNull(it.toBigDecimalOrNull()) { "wrong amount format, use only digits" } } } + NoNameShadowing:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2$mappedProviders + NoNameShadowing:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.txId == txId } + + 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..39d4e0ed16 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) @@ -71,11 +72,8 @@ dependencies { // endregion // region Tests - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(projects.common.test) + testImplementation(projects.test.core) // endregion } \ No newline at end of file diff --git a/data/tokens/detekt-baseline-debug.xml b/data/tokens/detekt-baseline-debug.xml new file mode 100644 index 0000000000..8be9ea8bd3 --- /dev/null +++ b/data/tokens/detekt-baseline-debug.xml @@ -0,0 +1,13 @@ + + + + + MultilineLambdaItParameter:CustomTokensMerger.kt$CustomTokensMerger${ Timber.e(it, "Unable to fetch token:\n$token") null } + MultilineLambdaItParameter:DefaultCurrenciesRepository.kt$DefaultCurrenciesRepository${ it.networkId == blockchainNetworkId && compareIdWithMigrations(it, coinId) && it.derivationPath == derivationPath.value } + NullableToStringCall:AccountListCryptoCurrenciesFetcher.kt$AccountListCryptoCurrenciesFetcher$${this::class.simpleName} + NullableToStringCall:DefaultMultiWalletCryptoCurrenciesFetcher.kt$DefaultMultiWalletCryptoCurrenciesFetcher$${this::class.simpleName} + SuspendFunSwallowedCancellation:DefaultCurrenciesRepository.kt$DefaultCurrenciesRepository$runCatching + SuspendFunWithFlowReturnType:DefaultCurrenciesRepository.kt$DefaultCurrenciesRepository$suspend + UseOrEmpty:DefaultYieldSupplyWarningsViewedRepository.kt$DefaultYieldSupplyWarningsViewedRepository$appPreferencesStore.getObjectSet<String>(PreferencesKeys.YIELD_SUPPLY_WARNINGS_STATES_KEY).firstOrNull() ?: emptySet() + + 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..8d06bf0af1 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,11 +41,12 @@ internal class MultiWalletCryptoCurrenciesFetcherModule { AccountListCryptoCurrenciesFetcher( userWalletsStore = userWalletsStore, walletAccountsFetcher = walletAccountsFetcher, + expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, ) } else { DefaultMultiWalletCryptoCurrenciesFetcher( - demoConfig = DemoConfig(), + demoConfig = DemoConfig, userWalletsStore = userWalletsStore, tangemTechApi = tangemTechApi, customTokensMerger = CustomTokensMerger( @@ -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/MultiWalletCryptoCurrenciesSupplierModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesSupplierModule.kt index b2bac92e91..7490f3949e 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesSupplierModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesSupplierModule.kt @@ -19,7 +19,7 @@ internal class MultiWalletCryptoCurrenciesSupplierModule { ): MultiWalletCryptoCurrenciesSupplier { return object : MultiWalletCryptoCurrenciesSupplier( factory = factory, - keyCreator = { "multi_crypto_currency_${it.userWalletId}" }, + keyCreator = { "multi_crypto_currency_${it.userWalletId.stringValue}" }, ) {} } } \ No newline at end of file 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..1941b6ee4b 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 @@ -10,11 +10,13 @@ import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedRepository 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.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository @@ -40,11 +42,13 @@ internal object TokensDataModule { walletManagersFacade: WalletManagersFacade, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, - expressServiceLoader: ExpressServiceLoader, + expressServiceFetcher: ExpressServiceFetcher, excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, tokensSaver: UserTokensSaver, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + accountsFeatureToggles: AccountsFeatureToggles, ): CurrenciesRepository { return DefaultCurrenciesRepository( tangemTechApi = tangemTechApi, @@ -52,12 +56,14 @@ internal object TokensDataModule { walletManagersFacade = walletManagersFacade, cacheRegistry = cacheRegistry, userTokensResponseStore = userTokensResponseStore, - expressServiceLoader = expressServiceLoader, + expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, excludedBlockchains = excludedBlockchains, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, userTokensSaver = tokensSaver, responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + accountsFeatureToggles = accountsFeatureToggles, ) } 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..e2988f92ce 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,21 +10,23 @@ 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.account.featuretoggle.AccountsFeatureToggles 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 import com.tangem.domain.models.wallet.* +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade @@ -40,16 +42,18 @@ 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, private val userTokensResponseStore: UserTokensResponseStore, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, excludedBlockchains: ExcludedBlockchains, ) : CurrenciesRepository { - private val demoConfig = DemoConfig() + private val demoConfig = DemoConfig private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains) private val userTokensResponseFactory = UserTokensResponseFactory() private val customTokensMerger = CustomTokensMerger( @@ -74,26 +78,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, @@ -289,7 +273,7 @@ internal class DefaultCurrenciesRepository( } } - override fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> { + private fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> { return channelFlow { val userWallet = userWalletsStore.getSyncStrict(userWalletId) ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) @@ -322,51 +306,36 @@ 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( + override suspend fun getNetworkCoin( userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency = withContext(dispatchers.io) { - getMultiCurrencyWalletCurrency(userWalletId, id.value) + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): CryptoCurrency.Coin { + return if (accountsFeatureToggles.isFeatureEnabled) { + getNetworkCoinNew(userWalletId, networkId, derivationPath) + } else { + getNetworkCoinLegacy(userWalletId, networkId, derivationPath) + } } - override suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency = - withContext(dispatchers.io) { - val userWallet = userWalletsStore.getSyncStrict(userWalletId) - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) + private suspend fun getNetworkCoinNew( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): CryptoCurrency.Coin = withContext(dispatchers.default) { + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId), + ) + .orEmpty() + .find { currency -> + currency is CryptoCurrency.Coin && + currency.network.id.rawId == networkId.rawId && + currency.network.derivationPath == derivationPath + } as? CryptoCurrency.Coin + ?: error("Unable to find coin for network ID: $networkId") + } - 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( + private suspend fun getNetworkCoinLegacy( userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath, @@ -668,15 +637,15 @@ internal class DefaultCurrenciesRepository( } 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 +654,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 +666,7 @@ internal class DefaultCurrenciesRepository( skipCache = refresh, block = { coroutineScope { - launch { expressServiceLoader.update(userWallet, tokens) } + launch { expressServiceFetcher.fetch(userWallet, tokens) } } }, ) @@ -731,6 +700,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..7535e139d9 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,14 +1,17 @@ package com.tangem.data.tokens import arrow.core.left -import com.tangem.common.test.utils.assertEither -import com.tangem.common.test.utils.assertEitherRight +import arrow.core.right 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 import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher +import com.tangem.test.core.assertEither +import com.tangem.test.core.assertEitherRight import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest @@ -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..991d4f40f5 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 @@ -4,25 +4,24 @@ import arrow.core.left import arrow.core.right import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.assertEither import com.tangem.data.common.currency.CardCryptoCurrencyFactory 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.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 import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher +import com.tangem.test.core.assertEither import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest @@ -45,17 +44,17 @@ 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(), + demoConfig = DemoConfig, userWalletsStore = userWalletsStore, tangemTechApi = tangemTechApi, customTokensMerger = customTokensMerger, 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/transaction/detekt-baseline-debug.xml b/data/transaction/detekt-baseline-debug.xml new file mode 100644 index 0000000000..dd2976b413 --- /dev/null +++ b/data/transaction/detekt-baseline-debug.xml @@ -0,0 +1,11 @@ + + + + + CastNullableToNonNullableType:DefaultTransactionRepository.kt$DefaultTransactionRepository$as + NoNameShadowing:DefaultTransactionRepository.kt$DefaultTransactionRepository$amount + NoNameShadowing:DefaultTransactionRepository.kt$DefaultTransactionRepository$destination + NullableBooleanCheck:DefaultWalletAddressServiceRepository.kt$DefaultWalletAddressServiceRepository$(walletManager as? NearWalletManager)?.validateAddress(address) ?: false + NullableToStringCall:DefaultTransactionRepository.kt$DefaultTransactionRepository$${walletManager?.wallet?.blockchain} + + diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt index 07898f241d..5cd4f6c806 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt @@ -16,6 +16,7 @@ import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal @@ -31,11 +32,11 @@ internal class DefaultFeeRepository( } override suspend fun getEthereumFeeWithoutGas( - userWallet: UserWallet, + userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): Fee.Ethereum { val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, network = cryptoCurrency.network, ) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index e6b6616813..b2f5a28882 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -44,7 +44,7 @@ internal object TransactionDataModule { fun providesFeeRepository(walletManagersFacade: WalletManagersFacade): FeeRepository { return DefaultFeeRepository( walletManagersFacade, - demoConfig = DemoConfig(), + demoConfig = DemoConfig, ) } diff --git a/data/txhistory/detekt-baseline-debug.xml b/data/txhistory/detekt-baseline-debug.xml new file mode 100644 index 0000000000..3dfda1c580 --- /dev/null +++ b/data/txhistory/detekt-baseline-debug.xml @@ -0,0 +1,13 @@ + + + + + BooleanPropertyNaming:TxHistoryPagingSource.kt$TxHistoryPagingSource.Params$val refresh: Boolean + MaxChainedCallsOnSameLine:DefaultTxHistoryRepository.kt$DefaultTxHistoryRepository$walletManager?.wallet?.recentTransactions?.last()?.hash.orEmpty() + MultilineLambdaItParameter:TxHistoryPageBatchFetcher.kt$TxHistoryPageBatchFetcher${ currentCoroutineContext().ensureActive() BatchFetchResult.Error(it) } + NamedArguments:DefaultTxHistoryRepository.kt$DefaultTxHistoryRepository$Params(userWalletId, currency, pageSize, refresh) + NamedArguments:TxHistoryDataModule.kt$TxHistoryDataModule$DefaultTxHistoryRepository( cacheRegistry, walletManagersFacade, userWalletsStore, txHistoryItemsStore, dispatchers, ) + SuspendFunSwallowedCancellation:TxHistoryPageBatchFetcher.kt$TxHistoryPageBatchFetcher$runCatching + UseOrEmpty:DefaultTxHistoryRepository.kt$DefaultTxHistoryRepository$txs ?: emptyList() + + diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 75c79ae815..287bd65478 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.domain.visa) implementation(projects.domain.card) implementation(projects.domain.wallets) + implementation(projects.domain.legacy) implementation(projects.domain.models) implementation(projects.domain.wallets.models) implementation(projects.domain.appCurrency.models) @@ -30,6 +31,8 @@ dependencies { implementation(projects.domain.tokens) implementation(projects.domain.networks) implementation(projects.domain.walletManager) + implementation(projects.domain.quotes) + implementation(projects.domain.common) /** Feature API - remove after removing [HotWalletFeatureToggles] */ implementation(projects.features.hotWallet.api) diff --git a/data/visa/detekt-baseline-debug.xml b/data/visa/detekt-baseline-debug.xml new file mode 100644 index 0000000000..bf83464b1c --- /dev/null +++ b/data/visa/detekt-baseline-debug.xml @@ -0,0 +1,29 @@ + + + + + MaxChainedCallsOnSameLine:DefaultVisaRepository.kt$DefaultVisaRepository$userWallet.requireColdWallet().scanResponse.card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } + MultilineLambdaItParameter:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository${ VisaDataToSignByCardWallet( request = request, hashToSign = it.result.hash, ) } + MultilineLambdaItParameter:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository${ VisaDataToSignByCustomerWallet( request = request, hashToSign = it.result.hash, ) } + MultilineLambdaItParameter:VisaApiRequestMaker.kt$VisaApiRequestMaker${ if (it is ApiResponseError.HttpException && it.code == ApiResponseError.HttpException.Code.UNAUTHORIZED ) { userWalletsStore.update(userWalletId) { userWallet -> userWallet.requireColdWallet().copy( scanResponse = userWallet.scanResponse.copy( // visaCardActivationStatus = VisaCardActivationStatus.RefreshTokenExpired, ), ) } } throw RefreshTokenExpiredException() } + MultilineLambdaItParameter:VisaTxDetailsFactory.kt$VisaTxDetailsFactory${ when (val txUrl = walletBlockchain.getExploreTxUrl(it)) { is TxExploreState.Url -> txUrl.url is TxExploreState.Unsupported -> "" } } + MultilineLambdaItParameter:VisaTxHistoryPagingSource.kt$VisaTxHistoryPagingSource${ it.toMutableMap().apply { this[cardPublicKey] = this[cardPublicKey].orEmpty() + response.transactions } } + MultilineLambdaItParameter:VisaTxHistoryPagingSource.kt$VisaTxHistoryPagingSource${ it.toMutableMap().apply { this[offset] = response.transactions.map(VisaTxHistoryItemConverter::convert) } } + NamedArguments:DefaultVisaRepository.kt$DefaultVisaRepository$parZip( dispatchers.io, { contractInfoProvider.getContractInfo( walletAddress = address, paymentAccountAddress = paymentAccountAddress, ) }, { getFiatRate() }, { contractInfo, fiatRate -> fetchedCurrencies.update { value -> value.apply { put(address, visaCurrencyFactory.create(userWallet, contractInfo, fiatRate)) } } }, ) + NoNameShadowing:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository$responseError + NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (config != null) return@withLock requireNotNull(config) + NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (provider != null) return@withLock requireNotNull(provider) + NullableToStringCall:DefaultOnboardingRepository.kt$DefaultOnboardingRepository$${error.message} + NullableToStringCall:TangemPayRequestPerformer.kt$TangemPayRequestPerformer$${error.message} + RedundantSuspendModifier:DefaultVisaRepository.kt$DefaultVisaRepository$suspend + SuspendFunSwallowedCancellation:DefaultVisaRepository.kt$DefaultVisaRepository$runCatching + SuspendFunSwallowedCancellation:TangemPayRequestPerformer.kt$TangemPayRequestPerformer$runCatching + SuspendFunSwallowedCancellation:VisaApiRequestMaker.kt$VisaApiRequestMaker$runCatching + UnreachableCode:VisaApiRequestMaker.kt$VisaApiRequestMaker$if (status is VisaCardActivationStatus.RefreshTokenExpired) { throw RefreshTokenExpiredException() } + UnreachableCode:VisaApiRequestMaker.kt$VisaApiRequestMaker$return (status as? VisaCardActivationStatus.Activated)?.visaAuthTokens ?: error("Visa card is not activated") + UnreachableCode:VisaApiRequestMaker.kt$VisaApiRequestMaker$val status: VisaCardActivationStatus = TODO("Fix visaCardActivationStatus retrieval") + UnsafeCallOnNullableType:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository$responseError.errorBody!! + UnsafeCallOnNullableType:DefaultVisaAuthRemoteDataSource.kt$DefaultVisaAuthRemoteDataSource$responseError.errorBody!! + UnsafeCallOnNullableType:VisaActivationStatusConverterWithState.kt$VisaActivationStatusConverterWithState$result.activationOrder!! + + 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..086f18ce33 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayTopUpDataFactory.kt @@ -0,0 +1,87 @@ +package com.tangem.data.pay + +import arrow.core.Either +import arrow.core.Either.Companion.catch +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 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 catch { + val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking() + val currency = getCurrency(wallet, chainId) + TangemPayTopUpData( + currency = currency, + walletId = wallet.walletId, + cryptoBalance = cryptoBalance, + fiatBalance = fiatBalance, + depositAddress = depositAddress, + receiveAddress = listOf(ReceiveAddressModel(nameService = NameService.Default, value = depositAddress)), + ) + }.mapLeft { exception -> + Timber.tag(TAG).e(exception) + 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..32c978f1b1 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,10 +4,9 @@ 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.VisaAuthTokens +import com.tangem.domain.visa.model.TangemPayAuthTokens +import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.sdk.api.TangemSdkManager import javax.inject.Inject @@ -16,40 +15,16 @@ 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() - - 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() + override suspend fun produceInitialCredentials(cardId: String): Either { + return when (val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId)) { + is CompletionResult.Failure<*> -> Either.Left(initialCredentials.error) + is CompletionResult.Success -> Either.Right(initialCredentials.data) } + } - override suspend fun refreshAuthTokens(refreshToken: String): Either = either { - visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens( - VisaAuthTokens.RefreshToken(refreshToken, authType = VisaAuthTokens.RefreshToken.Type.CardWallet), - ) + override suspend fun refreshAuthTokens(refreshToken: String): Either = either { + visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(refreshToken = refreshToken) .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..57640bba9d 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,18 @@ package com.tangem.data.pay.di -import com.tangem.data.pay.DefaultDataForReceiveFactory -import com.tangem.data.pay.repository.DefaultCardDetailsRepository +import com.tangem.data.pay.DefaultTangemPayTopUpDataFactory 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.domain.pay.repository.CardDetailsRepository +import com.tangem.data.pay.repository.DefaultTangemPayCardDetailsRepository +import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository +import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase +import com.tangem.domain.pay.TangemPayTopUpDataFactory import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase 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 @@ -37,11 +39,17 @@ internal interface TangemPayDataModule { @Binds @Singleton - fun bindCardDetailsRepository(repository: DefaultCardDetailsRepository): CardDetailsRepository + fun bindCardDetailsRepository(repository: DefaultTangemPayCardDetailsRepository): TangemPayCardDetailsRepository @Binds @Singleton - fun bindDataForReceiveFactory(factory: DefaultDataForReceiveFactory): DataForReceiveFactory + fun bindDataForReceiveFactory(factory: DefaultTangemPayTopUpDataFactory): TangemPayTopUpDataFactory + + @Binds + @Singleton + fun bindGetTangemPayCurrencyStatusUseCase( + impl: DefaultGetTangemPayCurrencyStatusUseCase, + ): GetTangemPayCurrencyStatusUseCase companion object { @Provides @@ -54,8 +62,10 @@ internal interface TangemPayDataModule { @Provides @Singleton - fun provideTangemPayIssueOrderUseCase(repository: OnboardingRepository): TangemPayIssueOrderUseCase { - return TangemPayIssueOrderUseCase(repository = repository) + fun provideProduceTangemPayInitialDataUseCase( + repository: OnboardingRepository, + ): ProduceTangemPayInitialDataUseCase { + return ProduceTangemPayInitialDataUseCase(repository = repository) } } } \ No newline at end of file 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 deleted file mode 100644 index 2875b51a63..0000000000 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.tangem.data.pay.repository - -import arrow.core.Either -import com.tangem.core.error.UniversalError -import com.tangem.data.pay.util.RainCryptoUtil -import com.tangem.data.visa.config.VisaLibLoader -import com.tangem.datasource.api.common.config.ApiConfig -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.domain.pay.model.TangemPayCardBalance -import com.tangem.domain.pay.model.TangemPayCardDetails -import com.tangem.domain.pay.repository.CardDetailsRepository -import javax.inject.Inject - -private const val TAG = "TangemPay: CardDetailsRepository" - -internal class DefaultCardDetailsRepository @Inject constructor( - private val tangemPayApi: TangemPayApi, - private val requestHelper: TangemPayRequestPerformer, - private val visaLibLoader: VisaLibLoader, - private val apiConfigsManager: ApiConfigsManager, - private val rainCryptoUtil: RainCryptoUtil, -) : CardDetailsRepository { - - override suspend fun getCardBalance(): Either { - return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.request { authHeader -> - tangemPayApi.getCardBalance(authHeader) - }.result ?: error("Cannot get card balance") - TangemPayCardBalance( - balance = result.availableBalance, - currencyCode = result.currency, - ) - } - } - - override suspend fun revealCardDetails(): Either { - return requestHelper.runWithErrorLogs(TAG) { - val publicKeyBase64 = getPublicKeyBase64() - val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64) - - val result = requestHelper.request { authHeader -> - tangemPayApi.revealCardDetails( - authHeader = authHeader, - body = CardDetailsRequest(sessionId = sessionId), - ) - }.result ?: error("Cannot reveal card details") - - val pan = rainCryptoUtil.decryptSecret( - base64Secret = result.pan.secret, - base64Iv = result.pan.iv, - secretKeyHex = secretKeyHex, - ) - - val cvv = rainCryptoUtil.decryptSecret( - base64Secret = result.cvv.secret, - base64Iv = result.cvv.iv, - secretKeyHex = secretKeyHex, - ) - - TangemPayCardDetails( - pan = pan, - cvv = cvv, - expirationYear = result.expirationYear, - expirationMonth = result.expirationMonth, - ) - } - } - - private suspend fun getPublicKeyBase64(): String { - val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment - return when (env) { - ApiEnvironment.DEV, - ApiEnvironment.DEV_2, - ApiEnvironment.DEV_3, - ApiEnvironment.STAGE, - ApiEnvironment.MOCK, - -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.dev - ApiEnvironment.PROD -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.prod - } - } -} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt index e7c91d654c..83b97182fe 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt @@ -1,26 +1,22 @@ package com.tangem.data.pay.repository import arrow.core.Either -import com.tangem.core.error.UniversalError import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.KycStartInfo import com.tangem.domain.pay.repository.KycRepository +import com.tangem.domain.visa.error.VisaApiError import javax.inject.Inject -private const val TAG = "TangemPay: KycRepository" - internal class DefaultKycRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, ) : KycRepository { - override suspend fun getKycStartInfo(): Either { - return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.request { authHeader -> - tangemPayApi.getKycAccess(authHeader = authHeader) - }.result - - KycStartInfo(token = result.token, locale = result.locale) - } + override suspend fun getKycStartInfo(userWalletId: UserWalletId): Either { + return requestHelper.performRequest( + userWalletId, + ) { authHeader -> tangemPayApi.getKycAccess(authHeader = authHeader) } + .map { KycStartInfo(token = it.result.token, locale = it.result.locale) } } } \ No newline at end of file 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..c6fa9b8d4e 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 @@ -1,136 +1,211 @@ package com.tangem.data.pay.repository import arrow.core.Either -import com.tangem.core.error.UniversalError 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.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +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 -import com.tangem.domain.pay.model.MainScreenCustomerInfo import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject private const val VALID_STATUS = "valid" private const val APPROVED_KYC_STATUS = "APPROVED" private const val TAG = "TangemPay: OnboardingRepository" +@Suppress("LongParameterList") internal class DefaultOnboardingRepository @Inject constructor( private val dispatcherProvider: CoroutineDispatcherProvider, private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, private val tangemPayStorage: TangemPayStorage, + private val authDataSource: TangemPayAuthDataSource, + private val cardFrozenStateStore: TangemPayCardFrozenStateStore, + private val userWalletsListManager: UserWalletsListManager, + private val userWalletsListRepository: UserWalletsListRepository, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : OnboardingRepository { - override suspend fun validateDeeplink(link: String): Either { - return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.request { - tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)) - }.result - result?.status == VALID_STATUS + // Save data for a session + private val lastFetchedCustomerInfoMap = ConcurrentHashMap() + + override suspend fun validateDeeplink(link: String): Either { + return requestHelper.performWithStaticToken { + tangemPayApi.validateDeeplink(body = DeeplinkValidityRequest(link = link)) + }.map { response -> + response.result?.status == VALID_STATUS } } - override suspend fun getCustomerInfo(): Either { - return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.request { authHeader -> - tangemPayApi.getCustomerMe(authHeader) - }.result - getCustomerInfo(result) + override suspend fun isTangemPayInitialDataProduced(userWalletId: UserWalletId): Boolean { + return withContext(dispatcherProvider.io) { + val customerWalletAddress = + tangemPayStorage.getCustomerWalletAddress(userWalletId) ?: return@withContext false + tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return@withContext false + + return@withContext true } } - override suspend fun getMainScreenCustomerInfo(): Either { - return requestHelper.runWithErrorLogs(TAG) { - val customerWalletAddress = requestHelper.getCustomerWalletAddress() + override suspend fun produceInitialData(userWalletId: UserWalletId) { + withContext(dispatcherProvider.io) { + val initialCredentials = authDataSource.produceInitialCredentials(cardId = getCardId(userWalletId)) + .fold( + ifLeft = { error -> error("Can not produce initial data: ${error.message}") }, + ifRight = { it }, + ) + // should storeCheckCustomerWalletResult because we already know this + tangemPayStorage.storeCheckCustomerWalletResult(userWalletId) + tangemPayStorage.storeCustomerWalletAddress( + userWalletId = userWalletId, + customerWalletAddress = initialCredentials.customerWalletAddress, + ) + tangemPayStorage.storeAuthTokens( + customerWalletAddress = initialCredentials.customerWalletAddress, + tokens = initialCredentials.authTokens, + ) + } + } - when (val orderId = tangemPayStorage.getOrderId(customerWalletAddress)) { - // If order id wasn't saved -> get customer info - null -> { - MainScreenCustomerInfo( - info = getCustomerInfoWithPersistedToken(), - orderStatus = OrderStatus.UNKNOWN, - ) - } - // If order id was saved -> check its status - else -> { - val orderStatus = getOrderStatus(orderId) - val customerInfo = when (orderStatus) { - // Kyc is passed and user waits for order creation -> no need to get customer info - OrderStatus.NEW, - OrderStatus.PROCESSING, - -> CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null) + override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either { + // TODO implement selector + return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerMe(authHeader) } + .map { response -> getCustomerInfo(userWalletId = userWalletId, response = response.result) } + } - // Order was created/cancelled -> clear order id and get customer info - OrderStatus.UNKNOWN, - OrderStatus.COMPLETED, - OrderStatus.CANCELED, - -> getCustomerInfoWithPersistedToken().also { - tangemPayStorage.clearOrderId(customerWalletAddress) - } - } - MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus) - } + override suspend fun clearOrderId(userWalletId: UserWalletId) { + withContext(dispatcherProvider.io) { + val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId) + tangemPayStorage.clearOrderId(customerWalletAddress = customerWalletAddress) + } + } + + override suspend fun getOrderId(userWalletId: UserWalletId): String? { + return withContext(dispatcherProvider.io) { + val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId) + tangemPayStorage.getOrderId(customerWalletAddress) + } + } + + override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? { + return lastFetchedCustomerInfoMap[userWalletId] + } + + override suspend fun createOrder(userWalletId: UserWalletId) = withContext(dispatcherProvider.io) { + launch { + requestHelper.runWithErrorLogs(TAG) { + val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId) + val result = requestHelper.request(userWalletId) { authHeader -> + tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress)) + }.result ?: error("Create order result is null") + + tangemPayStorage.storeOrderId(result.data.customerWalletAddress, result.id) } } } - override suspend fun createOrder(): Either = withContext(dispatcherProvider.io) { - requestHelper.runWithErrorLogs(TAG) { - val walletAddress = requestHelper.getCustomerWalletAddress() - val result = requestHelper.requestWithPersistedToken { authHeader -> - tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress)) - }.result ?: error("Create order result is null") - - tangemPayStorage.storeOrderId(result.data.customerWalletAddress, result.id) + private fun getCardId(userWalletId: UserWalletId): String { + val userWallet = if (hotWalletFeatureToggles.isHotWalletEnabled) { + userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId } + } else { + userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == userWalletId } + } ?: error("no userWallet found") + return if (userWallet is UserWallet.Cold) { + userWallet.cardId + } else { + TODO("[REDACTED_JIRA]") } } - private fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo { + private suspend fun getCustomerInfo( + userWalletId: UserWalletId, + response: CustomerMeResponse.Result?, + ): CustomerInfo { val card = response?.card val balance = response?.balance val paymentAccount = response?.paymentAccount val cardInfo = if (paymentAccount != null && card != null && balance != null) { CardInfo( lastFourDigits = card.cardNumberEnd, - balance = balance.availableBalance, - currencyCode = balance.currency, + balance = balance.fiat.availableBalance, + currencyCode = balance.fiat.currency, customerWalletAddress = paymentAccount.customerWalletAddress, depositAddress = response.depositAddress, ) } else { null } + val productInstance = response?.productInstance?.let { instance -> + val cardFrozenState = when (instance.status) { + CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen + else -> TangemPayCardFrozenState.Frozen + } + cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState) + + ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState) + } return CustomerInfo( - productInstance = response?.productInstance?.let { ProductInstance(id = it.id, status = it.status) }, + productInstance = productInstance, isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS, cardInfo = cardInfo, - ) - } - - private suspend fun getOrderStatus(orderId: String): OrderStatus { - val result = requestHelper.request { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - }.result ?: error("Order result is null") - - return when (result.status) { - OrderStatus.NEW.apiName -> OrderStatus.NEW - OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING - OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED - else -> OrderStatus.CANCELED + ).also { + lastFetchedCustomerInfoMap[userWalletId] = it } } - private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo { - val result = requestHelper.requestWithPersistedToken { authHeader -> - tangemPayApi.getCustomerMe(authHeader) - }.result - return getCustomerInfo(result) + override suspend fun getOrderStatus( + userWalletId: UserWalletId, + orderId: String, + ): Either { + return requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId) + }.map { response -> + when (response.result?.status) { + null -> OrderStatus.UNKNOWN + OrderStatus.NEW.apiName -> OrderStatus.NEW + OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING + OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED + else -> OrderStatus.CANCELED + } + } + } + + override suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either { + val hasTangemPay = tangemPayStorage.checkCustomerWalletResult(userWalletId) + if (hasTangemPay == true) { + return Either.Right(true) + } + + return requestHelper.performWithStaticToken { staticToken -> + tangemPayApi.checkCustomerWalletId( + authHeader = staticToken, + customerWalletId = userWalletId.stringValue, + ) + }.map { response -> + val id = response.id + if (!id.isNullOrEmpty()) { + tangemPayStorage.storeCheckCustomerWalletResult(userWalletId = userWalletId) + true + } else { + false + } + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt new file mode 100644 index 0000000000..b92ec16efe --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -0,0 +1,193 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.data.pay.util.RainCryptoUtil +import com.tangem.data.visa.config.VisaLibLoader +import com.tangem.datasource.api.common.config.ApiConfig +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.FreezeUnfreezeCardRequest +import com.tangem.datasource.api.pay.models.request.SetPinRequest +import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse +import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.wallet.UserWalletId +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.TangemPayCardDetailsRepository +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +private const val TAG = "TangemPay: CardDetailsRepository" + +@Suppress("LongParameterList") +internal class DefaultTangemPayCardDetailsRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, + private val visaLibLoader: VisaLibLoader, + private val apiConfigsManager: ApiConfigsManager, + private val rainCryptoUtil: RainCryptoUtil, + private val storage: TangemPayStorage, + private val cardFrozenStateStore: TangemPayCardFrozenStateStore, +) : TangemPayCardDetailsRepository { + + override suspend fun getCardBalance(userWalletId: UserWalletId): Either { + return requestHelper.runWithErrorLogs(TAG) { + val result = requestHelper.request(userWalletId) { authHeader -> + tangemPayApi.getCardBalance(authHeader) + }.result ?: error("Cannot get card balance") + TangemPayCardBalance( + balance = result.fiat.availableBalance, + currencyCode = result.fiat.currency, + ) + } + } + + override suspend fun revealCardDetails(userWalletId: UserWalletId): Either { + return requestHelper.runWithErrorLogs(TAG) { + val publicKeyBase64 = getPublicKeyBase64() + val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64) + + val result = requestHelper.request(userWalletId) { authHeader -> + tangemPayApi.revealCardDetails( + authHeader = authHeader, + body = CardDetailsRequest(sessionId = sessionId), + ) + }.result ?: error("Cannot reveal card details") + + val pan = rainCryptoUtil.decryptSecret( + base64Secret = result.pan.secret, + base64Iv = result.pan.iv, + secretKeyBytes = secretKeyBytes, + ) + + val cvv = rainCryptoUtil.decryptSecret( + base64Secret = result.cvv.secret, + base64Iv = result.cvv.iv, + secretKeyBytes = secretKeyBytes, + ) + secretKeyBytes.fill(0) + + TangemPayCardDetails( + pan = pan, + cvv = cvv, + expirationYear = result.expirationYear, + expirationMonth = result.expirationMonth, + ) + } + } + + override suspend fun setPin(userWalletId: UserWalletId, 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(userWalletId) { 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(userWalletId: UserWalletId): Either { + return requestHelper.runWithErrorLogs(TAG) { + storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress(userWalletId)) + } + } + + override suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either { + return requestHelper.runWithErrorLogs(TAG) { + storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(userWalletId), isDone = true) + } + } + + override suspend fun freezeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either { + cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) + return requestHelper.makeSafeRequest(userWalletId) { + tangemPayApi.freezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) + }.onLeft { + cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Unfrozen) + }.map { response -> + val state = when (response.result?.status) { + FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Frozen + FreezeUnfreezeCardResponse.Status.NEW, + FreezeUnfreezeCardResponse.Status.PROCESSING, + -> TangemPayCardFrozenState.Pending + FreezeUnfreezeCardResponse.Status.CANCELED, + null, + -> TangemPayCardFrozenState.Unfrozen + } + cardFrozenStateStore.store(cardId, state) + + state + } + } + + override suspend fun unfreezeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either { + cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) + return requestHelper.makeSafeRequest(userWalletId) { + tangemPayApi.unfreezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) + }.onLeft { + cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Frozen) + }.map { response -> + val state = when (response.result?.status) { + FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Unfrozen + FreezeUnfreezeCardResponse.Status.NEW, + FreezeUnfreezeCardResponse.Status.PROCESSING, + -> TangemPayCardFrozenState.Pending + FreezeUnfreezeCardResponse.Status.CANCELED, + null, + -> TangemPayCardFrozenState.Frozen + } + cardFrozenStateStore.store(cardId, state) + + state + } + } + + override fun cardFrozenState(cardId: String): Flow { + return cardFrozenStateStore.get(cardId) + } + + override suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? { + return cardFrozenStateStore.getSyncOrNull(cardId) + } + + private suspend fun getPublicKeyBase64(): String { + val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment + return when (env) { + ApiEnvironment.DEV, + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + ApiEnvironment.STAGE, + ApiEnvironment.MOCK, + -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.dev + ApiEnvironment.PROD -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.prod + } + } +} \ No newline at end of file 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..243e008c99 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,9 +1,12 @@ 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.models.wallet.UserWalletId import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig @@ -26,9 +29,13 @@ 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( + userWalletId: UserWalletId, batchSize: Int, context: TangemPayTxHistoryListBatchingContext, ): TangemPayTxHistoryListBatchFlow { @@ -36,18 +43,24 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( fetchDispatcher = dispatchers.io, context = context, generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 }, - batchFetcher = createFetcher(batchSize), + batchFetcher = createFetcher(userWalletId, batchSize), ).toBatchFlow() } private fun createFetcher( + userWalletId: UserWalletId, batchSize: Int, ): BatchFetcher> { return CursorBatchFetcher( prefetchDistance = batchSize, batchSize = batchSize, subFetcher = { request, _, _ -> - val items = loadItems(config = request.params, cursor = request.cursor, limit = request.limit) + val items = loadItems( + userWalletId = userWalletId, + config = request.params, + cursor = request.cursor, + limit = request.limit, + ) BatchFetchResult.Success( data = items, last = items.size < request.limit, @@ -59,6 +72,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( } private suspend fun loadItems( + userWalletId: UserWalletId, config: TangemPayTxHistoryListConfig, cursor: String?, limit: Int, @@ -66,7 +80,14 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( cacheRegistry.invokeOnExpire( key = getCacheKey(customerWalletAddress = config.customerWalletAddress, cursor = cursor), skipCache = config.shouldRefresh, - block = { fetch(customerWalletAddress = config.customerWalletAddress, cursor = cursor, pageSize = limit) }, + block = { + fetch( + userWalletId = userWalletId, + customerWalletAddress = config.customerWalletAddress, + cursor = cursor, + pageSize = limit, + ) + }, ) return txHistoryItemsStore.getSyncOrNull( @@ -79,12 +100,17 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( return "tangem_pay_tx_history_${customerWalletAddress}_${cursor ?: INITIAL_CURSOR}" } - private suspend fun fetch(customerWalletAddress: String, cursor: String?, pageSize: Int) { + private suspend fun fetch( + userWalletId: UserWalletId, + customerWalletAddress: String, + cursor: String?, + pageSize: Int, + ) { requestPerformer.runWithErrorLogs(TAG) { - val result = requestPerformer.request { authHeader -> + val result = requestPerformer.request(userWalletId) { 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..ecdc839b23 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 @@ -1,47 +1,46 @@ package com.tangem.data.pay.repository import arrow.core.Either +import arrow.core.left +import arrow.core.raise.catch +import arrow.core.right 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.squareup.wire.Instant import com.tangem.data.pay.util.TangemPayErrorConverter -import com.tangem.data.pay.util.TangemPayWalletsManager import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.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.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayAuthTokens +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 import javax.inject.Inject -@Suppress("LongParameterList") internal class TangemPayRequestPerformer @Inject constructor( @NetworkMoshi moshi: Moshi, + private val environmentConfigStorage: EnvironmentConfigStorage, private val dispatchers: CoroutineDispatcherProvider, 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 + private var refreshTokensJob: Deferred? = null private val errorConverter = TangemPayErrorConverter(moshi) - suspend fun runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either { + @Deprecated("Do not use this method") + suspend fun runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either { return try { val result = requestBlock() Either.Right(result) @@ -58,52 +57,82 @@ internal class TangemPayRequestPerformer @Inject constructor( } } - suspend fun request(requestBlock: suspend (header: String) -> ApiResponse): T = - withContext(dispatchers.io) { - performRequest( - requestBlock = requestBlock, - getTokens = ::getAccessTokens, - refreshTokens = ::refreshAuthTokens, - ) - } - - 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( + suspend fun makeSafeRequest( + userWalletId: UserWalletId, requestBlock: suspend (header: String) -> ApiResponse, - getTokens: (suspend () -> VisaAuthTokens), - refreshTokens: (suspend () -> VisaAuthTokens)? = null, - ): T = runCatching { - requestBlock("Bearer ${getTokens().accessToken}").getOrThrow() - }.getOrElse { error -> - val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED - if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) { - refreshOrJoin(refreshTokens) - performRequest(requestBlock, refreshTokens = null, getTokens = getTokens) - } else { - throw error - } + ): Either { + return performRequest(userWalletId, requestBlock) } - private suspend fun refreshOrJoin(refreshTokens: suspend () -> VisaAuthTokens): VisaAuthTokens { - val jobToAwait: Deferred = - refreshTokensMutex.withLock { - val current = refreshTokensJob - if (current == null || current.isCompleted) { - coroutineScope { - async { refreshTokens() }.also { refreshTokensJob = it } - } - } else { - current + @Deprecated("Use perform request instead", replaceWith = ReplaceWith("performRequest")) + suspend fun request( + userWalletId: UserWalletId, + requestBlock: suspend (header: String) -> + ApiResponse, + ): T = withContext(dispatchers.io) { + performRequest(userWalletId, requestBlock = requestBlock) + // to keep behaviour as previous + .fold( + ifRight = { it }, + ifLeft = { error -> error("Cannot perform request: $error") }, + ) + } + + suspend fun performWithStaticToken( + requestBlock: suspend (header: String) -> ApiResponse, + ): Either = withContext(dispatchers.io) { + catch( + block = { + val staticToken = + environmentConfigStorage.getConfigSync().bffStaticToken ?: error("BFF static token is null") + when (val apiResponse = requestBlock(staticToken)) { + is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left() + is ApiResponse.Success -> apiResponse.data.right() } + }, + catch = { errorConverter.convert(it).left() }, + ) + } + + suspend fun performRequest( + userWalletId: UserWalletId, + requestBlock: suspend (header: String) -> ApiResponse, + ): Either = withContext(dispatchers.io) { + catch( + block = { + val tokens = getAccessTokens(userWalletId) + val now = Instant.now() + val accessExpiresAt = Instant.ofEpochSecond(tokens.expiresAt) + val refreshExpiresAt = Instant.ofEpochSecond(tokens.refreshExpiresAt) + val apiResponse: ApiResponse = if (accessExpiresAt.isAfter(now)) { + requestBlock(tokens.getAuthHeader()) + } else if (accessExpiresAt.isBefore(now) && refreshExpiresAt.isAfter(now)) { + val newTokens = refreshOrJoin(refreshTokens = { refreshAuthTokens(userWalletId) }) + requestBlock(newTokens.getAuthHeader()) + } else { + return@catch VisaApiError.RefreshTokenExpired.left() + } + + when (apiResponse) { + is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left() + is ApiResponse.Success -> apiResponse.data.right() + } + }, + catch = { errorConverter.convert(it).left() }, + ).onLeft { visaApiError -> Timber.tag("TangemPayRequestPerformer").e(visaApiError.toString()) } + } + + private suspend fun refreshOrJoin(refreshTokens: suspend () -> TangemPayAuthTokens): TangemPayAuthTokens { + val jobToAwait: Deferred = refreshTokensMutex.withLock { + val current = refreshTokensJob + if (current == null || current.isCompleted) { + coroutineScope { + async { refreshTokens() }.also { refreshTokensJob = it } + } + } else { + current } + } val result = try { jobToAwait.await() } finally { @@ -116,52 +145,34 @@ internal class TangemPayRequestPerformer @Inject constructor( return result } - suspend fun getCustomerWalletAddress(): String = customerWalletAddress ?: fetchAuthInputData().address + suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String { + val existingAddress = customerWalletAddress.value + if (existingAddress != null) { + return existingAddress + } + val storedAddress = tangemPayStorage.getCustomerWalletAddress( + userWalletId = userWalletId, + ) ?: error("Can not find customer address") - private suspend fun getAccessTokens(): VisaAuthTokens { - return getAccessTokensIfSaved() ?: fetchTokens() + customerWalletAddress.value = storedAddress + return storedAddress } - 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) + private suspend fun getAccessTokens(userWalletId: UserWalletId): TangemPayAuthTokens { + val walletAddress = getCustomerWalletAddress(userWalletId) + 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") + private suspend fun refreshAuthTokens(userWalletId: UserWalletId): TangemPayAuthTokens { + val customerWalletAddress = getCustomerWalletAddress(userWalletId) + val refreshToken = getAccessTokens(userWalletId).refreshToken + 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/pay/util/TangemPayErrorConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt index d5f0ebf004..d5fa97ece5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt @@ -1,18 +1,19 @@ package com.tangem.data.pay.util import com.squareup.moshi.Moshi -import com.tangem.core.error.UniversalError import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter +import com.tangem.datasource.api.pay.models.response.VisaErrorResponse import com.tangem.domain.visa.error.VisaApiError import com.tangem.utils.converter.Converter -class TangemPayErrorConverter(moshi: Moshi) : Converter { +class TangemPayErrorConverter(moshi: Moshi) : Converter { - private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) + private val visaErrorAdapter by lazy { moshi.adapter(VisaErrorResponse::class.java) } - override fun convert(value: Throwable): UniversalError { + override fun convert(value: Throwable): VisaApiError { return if (value is ApiResponseError.HttpException) { + if (value.code == ApiResponseError.HttpException.Code.NOT_FOUND) return VisaApiError.NotPaeraCustomer + val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode return runCatching { visaErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayWalletsManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayWalletsManager.kt index 32c775d61a..b4c31a08cb 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayWalletsManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayWalletsManager.kt @@ -8,7 +8,8 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import javax.inject.Inject -internal class TangemPayWalletsManager @Inject constructor( +// TODO remove after implement wallet selector in pay +class TangemPayWalletsManager @Inject constructor( private val manager: UserWalletsListManager, private val repository: UserWalletsListRepository, private val hotWalletFeatureToggles: HotWalletFeatureToggles, diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt index baadf36595..78d5cc47eb 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt @@ -5,15 +5,13 @@ import com.squareup.moshi.Moshi import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.pay.models.request.* import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter import com.tangem.datasource.di.NetworkMoshi -import com.tangem.domain.visa.error.VisaApiError -import com.tangem.domain.visa.model.VisaAuthChallenge -import com.tangem.domain.visa.model.VisaAuthSession -import com.tangem.domain.visa.model.VisaAuthSignedChallenge -import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import javax.inject.Inject @@ -21,6 +19,7 @@ import javax.inject.Inject internal class DefaultVisaAuthRemoteDataSource @Inject constructor( @NetworkMoshi private val moshi: Moshi, private val visaAuthApi: TangemPayApi, + private val tangemPayAuthApi: TangemPayAuthApi, private val dispatchers: CoroutineDispatcherProvider, ) : VisaAuthRemoteDataSource { @@ -66,15 +65,19 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor( override suspend fun getCustomerWalletAuthChallenge( customerWalletAddress: String, + customerWalletId: String, ): Either = withContext(dispatchers.io) { request { - visaAuthApi.generateNonceByCustomerWallet( - GenerateNonceByCustomerWalletRequest(customerWalletAddress = customerWalletAddress), + tangemPayAuthApi.generateNonceByCustomerWallet( + request = GenerateNonceByCustomerWalletRequest( + customerWalletAddress = customerWalletAddress, + customerWalletId = customerWalletId, + ), ).getOrThrow() }.map { response -> VisaAuthChallenge.Wallet( - challenge = response.result.nonce, - session = VisaAuthSession(response.result.sessionId), + challenge = response.nonce, + session = VisaAuthSession(response.sessionId), ) } } @@ -83,37 +86,42 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor( sessionId: String, signature: String, nonce: String, - ): Either = withContext(dispatchers.io) { + ): Either = withContext(dispatchers.io) { request { - visaAuthApi.getTokenByCustomerWallet( - GetTokenByCustomerWalletRequest( + tangemPayAuthApi.getTokenByCustomerWallet( + request = GetTokenByCustomerWalletRequest( + authType = "customer_wallet", sessionId = sessionId, signature = signature, messageFormat = "Tangem Pay wants to sign in with your account. Nonce: $nonce", ), ).getOrThrow() }.map { response -> - VisaAuthTokens( - response.result.accessToken, - VisaAuthTokens.RefreshToken( - response.result.refreshToken, - VisaAuthTokens.RefreshToken.Type.CardWallet, - ), + TangemPayAuthTokens( + accessToken = response.accessToken, + expiresAt = response.expiresAt, + refreshToken = response.refreshToken, + refreshExpiresAt = response.refreshExpiresAt, ) } } override suspend fun refreshCustomerWalletAuthTokens( - refreshToken: VisaAuthTokens.RefreshToken, - ): Either = withContext(dispatchers.io) { + refreshToken: String, + ): Either = withContext(dispatchers.io) { request { - visaAuthApi.refreshCustomerWalletAccessToken( - RefreshCustomerWalletAccessTokenRequest(refreshToken = refreshToken.value), + tangemPayAuthApi.refreshCustomerWalletAccessToken( + request = RefreshCustomerWalletAccessTokenRequest( + authType = "customer_wallet", + refreshToken = refreshToken, + ), ).getOrThrow() }.map { response -> - VisaAuthTokens( - accessToken = response.result.accessToken, - refreshToken = refreshToken.copy(value = response.result.refreshToken), + TangemPayAuthTokens( + accessToken = response.accessToken, + expiresAt = response.expiresAt, + refreshToken = response.refreshToken, + refreshExpiresAt = response.refreshExpiresAt, ) } } 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..dd5e03e917 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,27 @@ 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 org.joda.time.DateTime +import org.joda.time.DateTimeZone 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,10 +31,12 @@ 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.withLocalZone(), amount = spend.amount, currency = Currency.getInstance(spend.currency), + localAmount = spend.localAmount, + localCurrency = spend.localCurrency?.let(Currency::getInstance), enrichedMerchantName = spend.enrichedMerchantName, merchantName = spend.merchantName, enrichedMerchantCategory = spend.enrichedMerchantCategory, @@ -41,18 +52,44 @@ internal object TangemPayTxHistoryItemConverter : ): TangemPayTxHistoryItem.Payment { return TangemPayTxHistoryItem.Payment( id = id, - date = payment.postedAt, + jsonRepresentation = paymentAdapter.toJson(payment), + date = payment.postedAt.withLocalZone(), 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, - date = fee.postedAt, + jsonRepresentation = feeAdapter.toJson(fee), + date = fee.postedAt.withLocalZone(), currency = Currency.getInstance(fee.currency), amount = fee.amount, + description = fee.description, ) } + + 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.withLocalZone(), + currency = Currency.getInstance("usd"), + amount = collateral.amount, + transactionHash = collateral.transactionHash, + ) + } + + private fun DateTime.withLocalZone(): DateTime { + return withZone(DateTimeZone.getDefault()) + } } \ 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/detekt-baseline-debug.xml b/data/wallet-connect/detekt-baseline-debug.xml new file mode 100644 index 0000000000..6d06073a70 --- /dev/null +++ b/data/wallet-connect/detekt-baseline-debug.xml @@ -0,0 +1,61 @@ + + + + + BooleanPropertyNaming:DefaultWcRequestService.kt$DefaultWcRequestService$val noHaveCached = cachedRequest.none { (_, hashParams) -> hash == hashParams } + BooleanPropertyNaming:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$val haveEmptyDto = emptyNetworksDto.isNotEmpty() + BooleanPropertyNaming:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$val haveEmptySessions = emptyNetworkSessions.isNotEmpty() + BooleanPropertyNaming:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$val haveSomeUnknown = unknownStoredSessions.isNotEmpty() + BooleanPropertyNaming:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$val haveSomeUnknownSdkSessions = unknownSdkSessions.isNotEmpty() + BooleanPropertyNaming:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$val someMigrate = migrateToAccountSession(inStore) + BooleanPropertyNaming:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$val someRemove = removeUnknownSessions(inStore, inSdk, associatedSessions) + BooleanPropertyNaming:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$var someMigrated = false + CastNullableToNonNullableType:WcEthSendTransactionUseCase.kt$WcEthSendTransactionUseCase$as + CastNullableToNonNullableType:WcEthSignTransactionUseCase.kt$WcEthSignTransactionUseCase$as + MultilineLambdaItParameter:BlockAidVerificationDelegate.kt$BlockAidVerificationDelegate${ Timber.e("Failed to verify transaction: ${it.localizedMessage}") emit(Lce.Error(it)) } + MultilineLambdaItParameter:DefaultWcPairUseCase.kt$DefaultWcPairUseCase${ Timber.tag(WC_TAG).e(it, "Failed to call pair $pairRequest") analytics.send( WcAnalyticEvents.PairFailed( errorCode = it.code, errorMessage = it.message, ), ) emit(WcPairState.Error(it)) } + MultilineLambdaItParameter:DefaultWcPairUseCase.kt$DefaultWcPairUseCase${ Timber.tag(WC_TAG).e(it, "Failed to verify DApp ${sessionProposal.name}") CheckDAppResult.FAILED_TO_VERIFY } + MultilineLambdaItParameter:DefaultWcPairUseCase.kt$DefaultWcPairUseCase${ analytics.send( WcAnalyticEvents.DAppConnectionFailed( errorCode = it.code, errorMessage = it.message, ), ) sdkDelegate.rejectSession(sdkSessionProposal.proposerPublicKey) Timber.tag(WC_TAG).e(it, "Failed to approve session ${sdkSessionProposal.name}") } + MultilineLambdaItParameter:DefaultWcPairUseCase.kt$DefaultWcPairUseCase${ analytics.send( WcAnalyticEvents.PairFailed( errorCode = it.code, errorMessage = it.message, ), ) emit(WcPairState.Error(it)) } + MultilineLambdaItParameter:DefaultWcPairUseCase.kt$DefaultWcPairUseCase${ if (it != null) { Timber.tag(WC_TAG).e(it, "Completed with error $pairRequest") } else { Timber.tag(WC_TAG).i("Completed successfully $pairRequest") } } + MultilineLambdaItParameter:DefaultWcPairUseCase.kt$DefaultWcPairUseCase${ val pairError: WcPairError = when (it) { is TimeoutCancellationException -> WcPairError.TimeoutException(it.message.orEmpty()) else -> WcPairError.Unknown(it.message.orEmpty()) } emit(WcPairState.Error(pairError)) } + MultilineLambdaItParameter:DefaultWcPairUseCase.kt$DefaultWcPairUseCase${ when (it) { is WcPairError -> it.left() else -> WcPairError.Unknown(it.localizedMessage.orEmpty()).left() } } + MultilineLambdaItParameter:DefaultWcRequestUseCaseFactory.kt$DefaultWcRequestUseCaseFactory${ Timber.tag(WC_TAG).e("$it") it.left() } + MultilineLambdaItParameter:DefaultWcRespondService.kt$DefaultWcRespondService${ if (continuation.isCompleted) return@respondSessionRequest Timber.tag(WC_TAG).e(it.throwable, "Failed respond for request $request") continuation.resume(WcRequestError.UnknownError(it.throwable).left()) } + MultilineLambdaItParameter:DefaultWcRespondService.kt$DefaultWcRespondService${ if (continuation.isCompleted) return@respondSessionRequest val result = when (val response = it.jsonRpcResponse) { is Wallet.Model.JsonRpcResponse.JsonRpcError -> { Timber.tag(WC_TAG).e("Failed respond $response for request $request") WcRequestError.WcRespondError( code = response.code, message = response.message, ).left() } is Wallet.Model.JsonRpcResponse.JsonRpcResult -> { Timber.tag(WC_TAG).i("Successful respond $response for request $request") if (response.result == null) { Timber.tag(WC_TAG).e( "Response result is null, but it should be String. Casted to empty", ) } (response.result ?: "").right() } } continuation.resume(result) } + MultilineLambdaItParameter:WcEthMessageSignUseCase.kt$LegacySdkHelper${ if (!it.isAscii()) return null Integer.toHexString(it.code) } + MultilineLambdaItParameter:WcEthMessageSignUseCase.kt$LegacySdkHelper${ val char = it.toInt().toChar() if (char.isAscii()) char else return null } + MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.AddEthereumChain) { WcEthMethod.AddEthereumChain(rawChain = it).right() } else { WcEthMethod.SwitchEthereumChain(rawChain = it).right() } } + MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.SignTransaction) { WcEthMethod.SignTransaction(transaction = it).right() } else { WcEthMethod.SendTransaction(transaction = it).right() } } + MultilineLambdaItParameter:WcNetworksConverter.kt$WcNetworksConverter${ val walletAddress = walletManagersFacade.getDefaultAddress(wallet.walletId, it) walletAddress?.lowercase() == caip10.accountAddress.lowercase() } + MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ proposalCallback.cancel() return@coroutineScope it.left() } + MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ store.removePendingApproval(forSave) it.left() } + MultilineLambdaItParameter:WcSdkSessionConverter.kt$WcSdkSessionConverter${ WcAppMetaDataConverter.convert( value = WcAppMetaDataConverter.Input( originUrl = value.originUrl, peerMetaData = it, ), ) } + MultilineLambdaItParameter:WcSdkSessionRequestConverter.kt$WcSdkSessionRequestConverter${ WcAppMetaDataConverter.convert( value = WcAppMetaDataConverter.Input( originUrl = value.originUrl, peerMetaData = it, ), ) } + MultilineLambdaItParameter:WcSolanaSignTransactionUseCase.kt$WcSolanaSignTransactionUseCase${ analytics.send(SolanaLargeTransactionStatus(SolanaLargeTransactionStatus.Status.Failed)) Timber.e(it.toString()) emit(state.toResult(parseSendError(it).left())) } + NamedArguments:AssociateNetworksDelegate.kt$AssociateNetworksDelegate$mapNetworksForPortfolio(wallet, null, requiredNamespaces, optionalNamespaces, sessionProposal) + NamedArguments:WcEthTxHelper.kt$WcEthTxHelper$ethSpecificFee(userWallet, currency, gasLimit, gasPrice) + NoNameShadowing:CaipNamespaceDelegate.kt$CaipNamespaceDelegate$optionalNamespaces + NoNameShadowing:CaipNamespaceDelegate.kt$CaipNamespaceDelegate$requiredNamespaces + NoNameShadowing:DefaultWcRespondService.kt$DefaultWcRespondService$response + NoNameShadowing:WcSolanaNetwork.kt$WcSolanaNetwork$request + NullCheckOnMutableProperty:WcEthSendTransactionUseCase.kt$WcEthSendTransactionUseCase$if (dAppFee != null) return dAppFee + NullCheckOnMutableProperty:WcEthSignTransactionUseCase.kt$WcEthSignTransactionUseCase$if (dAppFee != null) return dAppFee + NullableBooleanCheck:DefaultWcPairService.kt$DefaultWcPairService$existSessionTopic(request.uri).getOrNull() ?: false + NullableToStringCall:WcSolanaSignTransactionUseCase.kt$WcSolanaSignTransactionUseCase$${e.message} + SuspendFunSwallowedCancellation:DefaultWcPairService.kt$DefaultWcPairService$runCatching + SuspendFunSwallowedCancellation:DefaultWcPairUseCase.kt$DefaultWcPairUseCase$runCatching + UseAnyOrNoneInsteadOfFind:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$find { it.sdkModel.topic == dto.topic } + UseEmptyCounterpart:AssociateNetworksDelegate.kt$AssociateNetworksDelegate.Companion$listOf() + UseEmptyCounterpart:DefaultWcRequestService.kt$DefaultWcRequestService$setOf() + UseEmptyCounterpart:WcAppMetaDataConverter.kt$WcAppMetaDataConverter$listOf() + UseEmptyCounterpart:WcNetworksConverter.kt$WcNetworksConverter$listOf() + UseEmptyCounterpart:WcSdkSessionConverter.kt$WcSdkSessionConverter$listOf() + UseOrEmpty:AssociateNetworksDelegate.kt$AssociateNetworksDelegate.Companion$proposal.chains ?: listOf() + UseOrEmpty:DefaultWcRespondService.kt$DefaultWcRespondService$response.result ?: "" + UseOrEmpty:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$storeSession.url ?: sdkSession.metaData?.url ?: "" + UseOrEmpty:WcNetworksConverter.kt$WcNetworksConverter$getAccountStatus(accountId) ?.flattenCurrencies() ?.map { it.currency } ?.filterIsInstance<CryptoCurrency.Coin>() ?.map(CryptoCurrency.Coin::network) ?: emptyList() + UseOrEmpty:WcSdkSessionConverter.kt$WcSdkSessionConverter$session.chains ?: listOf() + UselessCallOnNotNull:DefaultWcRequestUseCaseFactory.kt$DefaultWcRequestUseCaseFactory$error.code().orEmpty() + + 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/wallet-manager/build.gradle.kts b/data/wallet-manager/build.gradle.kts index 9a21d4c8a3..31bb6bc3c8 100644 --- a/data/wallet-manager/build.gradle.kts +++ b/data/wallet-manager/build.gradle.kts @@ -45,9 +45,7 @@ dependencies { implementation(deps.timber) /** Testing libraries */ - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/data/wallet-manager/detekt-baseline-debug.xml b/data/wallet-manager/detekt-baseline-debug.xml new file mode 100644 index 0000000000..b5ce5d471d --- /dev/null +++ b/data/wallet-manager/detekt-baseline-debug.xml @@ -0,0 +1,12 @@ + + + + + MultilineLambdaItParameter:DefaultWalletManagersFacade.kt$DefaultWalletManagersFacade${ Token( name = it.name, symbol = it.symbol, contractAddress = it.contractAddress, decimals = it.decimals, id = it.id, ) } + MultilineLambdaItParameter:UpdateWalletManagerResultFactory.kt$UpdateWalletManagerResultFactory${ createCurrencyTransaction( txHistoryItemConverter = txHistoryItemConverter, data = it, ) } + NamedArguments:DefaultWalletManagersFacade.kt$DefaultWalletManagersFacade$getAndUpdateWalletManager(userWallet, blockchain, derivationPath, extraTokens) + UnnecessaryLet:DefaultWalletManagersFacade.kt$DefaultWalletManagersFacade$let(txHistoryStateConverter::convert) + UnsafeCallOnNullableType:WalletManagerFactory.kt$blockchain.getTestnetVersion()!! + UnsafeCallOnNullableType:WalletManagerFactory.kt$scanResponse.secondTwinPublicKey!! + + diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index f746aacfd4..5b0f50b11d 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -64,7 +64,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( blockchainSDKFactory: BlockchainSDKFactory, ) : WalletManagersFacade { - private val demoConfig by lazy { DemoConfig() } + private val demoConfig by lazy { DemoConfig } private val resultFactory by lazy { UpdateWalletManagerResultFactory() } private val walletManagerFactory by lazy { WalletManagerFactory(blockchainSDKFactory) } private val sdkTokenConverter by lazy { SdkTokenConverter() } diff --git a/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt index 94d6776409..6dcd1c2c61 100644 --- a/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt +++ b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/UpdateWalletManagerResultFactoryTest.kt @@ -1,21 +1,14 @@ package com.tangem.data.walletmanager import com.google.common.truth.Truth -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Token -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionStatus -import com.tangem.blockchain.common.Wallet -import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.* import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.common.test.domain.walletmanager.MockUpdateWalletManagerResultFactory -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo +import com.tangem.test.core.ProvideTestModels import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk diff --git a/data/wallets/detekt-baseline-debug.xml b/data/wallets/detekt-baseline-debug.xml new file mode 100644 index 0000000000..26f5591932 --- /dev/null +++ b/data/wallets/detekt-baseline-debug.xml @@ -0,0 +1,24 @@ + + + + + MultilineLambdaItParameter:DefaultColdMapDerivationsRepository.kt$DefaultColdMapDerivationsRepository${ networkFactory.create( blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null, extraDerivationPath = null, userWallet = userWallet, ) } + MultilineLambdaItParameter:DefaultDerivationsRepository.kt$DefaultDerivationsRepository${ userWallet.update(it.first) it.second } + MultilineLambdaItParameter:DefaultHotMapDerivationsRepository.kt$DefaultHotMapDerivationsRepository${ networkFactory.create( blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null, extraDerivationPath = null, userWallet = userWallet, ) } + MultilineLambdaItParameter:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository${ AttemptsPersistentData( attempts = it[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0, bootCount = it[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0, deadline = it[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L, ) } + MultilineLambdaItParameter:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository${ it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(noAuthAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockBootKey(noAuthAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(noAuthAttemptId.attemptIdKey())) } + MultilineLambdaItParameter:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository${ while (true) { emit(toState(id, it.attempts, it.deadline, it.bootCount)) val remaining = remainingSeconds(it.deadline, it.bootCount) if (remaining <= 0) break delay(timeMillis = 1000) } } + MultilineLambdaItParameter:DefaultHotWalletAccessor.kt$DefaultHotWalletAccessor${ block(UnlockHotWallet(hotWalletId, it)).also { hotWalletPasswordRequester.successfulAuthentication() hotWalletPasswordRequester.dismiss() } } + MultilineLambdaItParameter:DefaultHotWalletAccessor.kt$DefaultHotWalletAccessor${ tangemHotSdk.getContextUnlock(it).also { unlockHotWallet -> contextualUnlockHotWallet[hotWalletId] = unlockHotWallet } } + MultilineLambdaItParameter:TangemHotWalletSigner.kt$TangemHotWalletSigner${ Timber.e(it) return if (it is TangemSdkError) { CompletionResult.Failure(it) } else { CompletionResult.Failure(TangemSdkError.ExceptionError(it)) } } + NamedArguments:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository$toState(id, count, deadline, boot) + NamedArguments:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository$toState(id, it.attempts, it.deadline, it.bootCount) + SuspendFunSwallowedCancellation:DefaultHotWalletAccessor.kt$DefaultHotWalletAccessor$runCatching + SuspendFunSwallowedCancellation:TangemHotWalletSigner.kt$TangemHotWalletSigner$runCatching + UnnecessaryLet:MissedDerivationsFinder.kt$MissedDerivationsFinder$let(::findByNetworks) + UnusedImports:DefaultDerivationsRepository.kt$import com.tangem.common.map + UseOrEmpty:DefaultColdMapDerivationsRepository.kt$DefaultColdMapDerivationsRepository$oldKeys[walletKey] ?: emptyMap() + UseOrEmpty:DefaultHotMapDerivationsRepository.kt$DefaultHotMapDerivationsRepository$oldKeys[walletKey] ?: emptyMap() + VarCouldBeVal:DefaultHotWalletAccessor.kt$DefaultHotWalletAccessor$private var contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap() + + 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..ba985743b4 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 @@ -4,26 +4,22 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter -import com.tangem.data.wallets.converters.WalletIdBodyConverter import com.tangem.datasource.api.common.AuthProvider 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.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status import com.tangem.datasource.api.tangemTech.models.WalletBody +import com.tangem.datasource.api.tangemTech.models.WalletType 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 +33,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 +47,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) @@ -69,25 +64,25 @@ internal class DefaultWalletsRepository( } override suspend fun useBiometricAuthentication(): Boolean { - val useBiometricAuthentication = appPreferencesStore.getSyncOrNull( + val shouldUseBiometricAuth = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY, ) - if (useBiometricAuthentication != null) { - return useBiometricAuthentication + if (shouldUseBiometricAuth != null) { + return shouldUseBiometricAuth } - val legacySaveWalletsInTheApp = appPreferencesStore.getSyncOrNull( + val isLegacySaveWalletsInTheApp = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.SAVE_USER_WALLETS_KEY, ) - if (legacySaveWalletsInTheApp != null) { + if (isLegacySaveWalletsInTheApp != null) { // Migrate legacy setting to new one appPreferencesStore.store( key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY, - value = legacySaveWalletsInTheApp, + value = isLegacySaveWalletsInTheApp, ) - return legacySaveWalletsInTheApp + return isLegacySaveWalletsInTheApp } else { // Default value for new users setUseBiometricAuthentication(false) @@ -100,25 +95,25 @@ internal class DefaultWalletsRepository( } override suspend fun requireAccessCode(): Boolean { - val requireAccessCode = appPreferencesStore.getSyncOrNull( + val isRequireAccessCode = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, ) - if (requireAccessCode != null) { - return requireAccessCode + if (isRequireAccessCode != null) { + return isRequireAccessCode } - val legacyShouldSaveAccessCode = appPreferencesStore.getSyncOrNull( + val isLegacyShouldSaveAccessCode = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, ) - if (legacyShouldSaveAccessCode != null) { + if (isLegacyShouldSaveAccessCode != null) { // Migrate legacy setting to new one appPreferencesStore.store( key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, - value = legacyShouldSaveAccessCode.not(), + value = isLegacyShouldSaveAccessCode.not(), ) - return legacyShouldSaveAccessCode.not() + return isLegacyShouldSaveAccessCode.not() } else { // Default value for new users setRequireAccessCode(true) @@ -137,10 +132,10 @@ internal class DefaultWalletsRepository( } override suspend fun setHasWalletsWithRing(userWalletId: UserWalletId) { - appPreferencesStore.editData { - val added = it[PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY].orEmpty() + appPreferencesStore.editData { mutablePreferences -> + val added = mutablePreferences[PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY].orEmpty() - it[PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY] = added + userWalletId.stringValue + mutablePreferences[PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY] = added + userWalletId.stringValue } } @@ -148,8 +143,8 @@ internal class DefaultWalletsRepository( return channelFlow { launch { seedPhraseNotificationVisibilityStore.get() - .map { - it.getOrDefault( + .map { map -> + map.getOrDefault( key = userWalletId, defaultValue = SeedPhraseNotificationsStatus.NOT_NEEDED, ) @@ -176,8 +171,8 @@ internal class DefaultWalletsRepository( tangemTechApi.getSeedPhraseNotificationStatus(walletId = userWalletId.stringValue).getOrThrow() }.fold( onSuccess = { it.status }, - onFailure = { - if (it is HttpException && it.code == HttpException.Code.NOT_FOUND) { + onFailure = { throwable -> + if (throwable is HttpException && throwable.code == HttpException.Code.NOT_FOUND) { Status.NOTIFIED } else { Status.NOT_NEEDED @@ -252,10 +247,12 @@ internal class DefaultWalletsRepository( updateNotificationVisibility(id = userWalletId, value = SeedPhraseNotificationsStatus.NOT_NEEDED) } - override suspend fun markWallet2WasCreated(userWalletId: UserWalletId) { + override suspend fun createWallet(userWalletId: UserWalletId) { runCatching(dispatchers.io) { - tangemTechApi.markUserWallerWasCreated( - body = MarkUserWalletWasCreatedBody(userWalletId = userWalletId.stringValue), + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + tangemTechApi.createWallet( + body = WalletIdBodyConverter.convert(userWallet = userWallet), ) } } @@ -283,8 +280,8 @@ internal class DefaultWalletsRepository( } private suspend fun updateNotificationVisibility(id: UserWalletId, value: SeedPhraseNotificationsStatus) { - return seedPhraseNotificationVisibilityStore.update { - it.toMutableMap().apply { + return seedPhraseNotificationVisibilityStore.update { map -> + map.toMutableMap().apply { this[id] = value } } @@ -296,23 +293,23 @@ internal class DefaultWalletsRepository( override fun nftEnabledStatuses(): Flow> = appPreferencesStore .getObjectMap(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) - .map { it.mapKeys { UserWalletId(it.key) } } + .map { map -> map.mapKeys { UserWalletId(it.key) } } override suspend fun enableNFT(userWalletId: UserWalletId) { - appPreferencesStore.editData { - it.setObjectMap( + appPreferencesStore.editData { mutablePreferences -> + mutablePreferences.setObjectMap( key = PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY, - value = it.getObjectMap(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) + value = mutablePreferences.getObjectMap(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) .plus(userWalletId.stringValue to true), ) } } override suspend fun disableNFT(userWalletId: UserWalletId) { - appPreferencesStore.editData { - it.setObjectMap( + appPreferencesStore.editData { mutablePreferences -> + mutablePreferences.setObjectMap( key = PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY, - value = it.getObjectMap(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) + value = mutablePreferences.getObjectMap(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) .plus(userWalletId.stringValue to false), ) } @@ -328,10 +325,10 @@ internal class DefaultWalletsRepository( .firstOrNull() == true override suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean) { - appPreferencesStore.editData { - it.setObjectMap( + appPreferencesStore.editData { mutablePreferences -> + mutablePreferences.setObjectMap( key = PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY, - value = it.getObjectMap(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY) + value = mutablePreferences.getObjectMap(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY) .plus(userWalletId.stringValue to isEnabled), ) } @@ -348,9 +345,11 @@ internal class DefaultWalletsRepository( } override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) { + val userWallet = userWalletsStore.getSyncOrNull(key = UserWalletId(walletId)) + tangemTechApi.updateWallet( walletId = walletId, - body = WalletBody(name = walletName), + body = WalletBody(name = walletName, type = WalletType.from(userWallet)), ).getOrThrow() } @@ -381,10 +380,16 @@ internal class DefaultWalletsRepository( override suspend fun associateWallets(applicationId: String, wallets: List) = withContext(dispatchers.io) { val publicKeys = authProvider.getCardsPublicKeys() - val walletsBody = wallets.filterIsInstance().map { userWallet -> + val walletsBody = wallets.map { userWallet -> WalletIdBodyConverter.convert( userWallet = userWallet, - publicKeys = publicKeys.filterKeys { userWallet.cardsInWallet.contains(it) }, + publicKeys = if (userWallet is UserWallet.Cold) { + publicKeys.filterKeys { + userWallet.cardsInWallet.contains(it) + } + } else { + emptyMap() + }, ) } @@ -403,18 +408,20 @@ internal class DefaultWalletsRepository( promoCode = promoCode, address = bitcoinAddress, ), - ).fold({ - return@fold it.status.right() - }, { error -> - val error = when (error) { - is HttpException -> when (error.code) { - HttpException.Code.NOT_FOUND -> ActivatePromoCodeError.InvalidPromoCode - HttpException.Code.CONFLICT -> ActivatePromoCodeError.PromocodeAlreadyUsed + ).fold( + onSuccess = { it.status.right() }, + onError = { apiResponseError -> + val error = when (apiResponseError) { + is HttpException -> when (apiResponseError.code) { + HttpException.Code.NOT_FOUND -> ActivatePromoCodeError.InvalidPromoCode + HttpException.Code.CONFLICT -> ActivatePromoCodeError.PromocodeAlreadyUsed + else -> ActivatePromoCodeError.ActivationFailed + } else -> ActivatePromoCodeError.ActivationFailed } - else -> ActivatePromoCodeError.ActivationFailed - } - return@fold error.left() - }) + + error.left() + }, + ) } } \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index 5e60776268..00817f74de 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -165,10 +165,12 @@ class DefaultHotWalletAccessor @Inject constructor( block(auth) }.getOrElse { exception -> if (auth is HotAuth.Biometry && exception.isBiometryError()) { + val shouldRetryBiometry = exception is TangemSdkError.AuthenticationCanceled + // fallback to password if biometry fails val passAuth = requestPassword( hotWalletId = hotWalletId, - hasBiometry = true, + hasBiometry = shouldRetryBiometry, ) return@getOrElse runCatchingWrongPassInternal( diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt index d2e030bc54..e2a9090de4 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt @@ -8,9 +8,9 @@ import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError.HttpException import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.WalletResponse import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody import com.tangem.datasource.api.tangemTech.models.PromocodeActivationResponse +import com.tangem.datasource.api.tangemTech.models.WalletResponse import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.domain.models.wallet.UserWallet @@ -226,15 +226,17 @@ class DefaultWalletsRepositoryTest { // THEN coVerify(exactly = 1) { tangemTechApi.associateApplicationIdWithWallets( - eq(applicationId), - match { body -> + applicationId = eq(applicationId), + body = match { body -> body.size == 2 && body.any { - it.walletId == wallet1Id && it.cards.any { card -> card.cardPublicKey == "public_key_1" } && + it.walletId == wallet1Id && + it.cards!!.any { card -> card.cardPublicKey == "public_key_1" } && it.name == "Wallet 1" } && body.any { - it.walletId == wallet2Id && it.cards.any { card -> card.cardPublicKey == "public_key_2" } && + it.walletId == wallet2Id && + it.cards!!.any { card -> card.cardPublicKey == "public_key_2" } && it.name == "Wallet 2" } }, diff --git a/data/yield-supply/detekt-baseline-debug.xml b/data/yield-supply/detekt-baseline-debug.xml new file mode 100644 index 0000000000..422ee5de58 --- /dev/null +++ b/data/yield-supply/detekt-baseline-debug.xml @@ -0,0 +1,15 @@ + + + + + BooleanPropertyNaming:DefaultYieldSupplyTransactionRepository.kt$DefaultYieldSupplyTransactionRepository$val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS + MultilineLambdaItParameter:DefaultYieldSupplyRepository.kt$DefaultYieldSupplyRepository${ it.type == TxInfo.TransactionType.YieldSupply.Enter || it.type == TxInfo.TransactionType.Approve && (it.interactionAddressType as? TxInfo.InteractionAddressType.Contract)?.address == yieldAddress } + NoNameShadowing:DefaultYieldSupplyTransactionRepository.kt$DefaultYieldSupplyTransactionRepository$maxNetworkFee + NullableBooleanCheck:DefaultYieldSupplyRepository.kt$DefaultYieldSupplyRepository$(walletManager as? YieldSupplyProvider)?.isSupported() ?: false + NullableBooleanCheck:DefaultYieldSupplyTransactionRepository.kt$DefaultYieldSupplyTransactionRepository$yieldSupplyStatus?.isActive ?: false + NullableBooleanCheck:DefaultYieldSupplyTransactionRepository.kt$DefaultYieldSupplyTransactionRepository$yieldSupplyStatus?.isAllowedToSpend ?: false + NullableBooleanCheck:DefaultYieldSupplyTransactionRepository.kt$DefaultYieldSupplyTransactionRepository$yieldSupplyStatus?.isInitialized ?: false + NullableBooleanCheck:YieldMarketTokenConverter.kt$YieldMarketTokenConverter$value.isActive ?: false + SuspendFunSwallowedCancellation:DefaultYieldSupplyTransactionRepository.kt$DefaultYieldSupplyTransactionRepository$runCatching + + diff --git a/detekt_baseline_report.txt b/detekt_baseline_report.txt new file mode 100644 index 0000000000..3a9f9a9044 --- /dev/null +++ b/detekt_baseline_report.txt @@ -0,0 +1,102 @@ +========================================== +Detekt Baseline Updater & Issue Counter +========================================== +Date: 2025-11-27 14:13:20 + +Step 1: Running detekt to check for new issues... + +✓ Detekt passed - no new issues found + +Step 2: Updating detekt baseline for debug variant... + + +Baseline updated successfully! + +========================================== +Counting issues in baseline files... +========================================== + +Summary: + Total Issues: 1593 + Modules with Issues: 68 + Average Issues per Module: 23 + +Progress: + Fixed: 209 out of 1802 (11%) + Remaining: 1593 + +========================================== +All Modules with Issues (sorted by count) +========================================== + +Module Issues +──────────────────────────────────────────────────────────────── +features/wallet/impl 169 +features/markets/impl 155 +features/onboarding-v2/impl 131 +features/send-v2/impl 80 +features/swap/impl 73 +features/hot-wallet/impl 57 +features/staking/impl 56 +data/wallet-connect 55 +features/swap-v2/impl 53 +features/walletconnect/impl 51 +features/tokendetails/impl 49 +features/manage-tokens/impl 45 +domain/wallets 39 +features/nft/impl 36 +features/tester/impl 31 +domain/tokens 28 +features/swap/domain 27 +core/ui 27 +features/yield-supply/impl 26 +common/ui 26 +data/visa 23 +features/tangempay/details/impl 22 +data/nft 20 +data/wallets 18 +features/swap/data 15 +data/swap 13 +features/token-recieve/impl 11 +features/qr-scanning/impl 11 +domain/account/status 11 +data/onramp 11 +data/manage-tokens 11 +core/datasource 11 +features/referral/impl 10 +features/details/impl 10 +data/markets 10 +domain/staking 9 +data/yield-supply 9 +data/networks 9 +features/welcome/impl 8 +features/home/impl 8 +domain/transaction 8 +libs/tangem-sdk-api 7 +features/onramp/impl 7 +data/txhistory 7 +data/tokens 7 +data/account 7 +features/send-v2/api 6 +domain/markets 6 +data/wallet-manager 6 +libs/visa 5 +domain/legacy 5 +data/transaction 5 +features/referral/domain 4 +features/biometry/impl 4 +features/account/impl 4 +features/account/api 4 +data/promo 4 +core/config-toggles 4 +features/wallet-settings/impl 3 +features/txhistory/impl 3 +features/tangempay/onboarding/impl 3 +features/create-wallet-start/impl 3 +domain/manage-tokens 3 +domain/demo/models 3 +domain/card 3 +data/feedback 3 +common/routing 3 +features/yield-supply/api 2 +──────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/domain/account/build.gradle.kts b/domain/account/build.gradle.kts index 75db105c86..da17a216f8 100644 --- a/domain/account/build.gradle.kts +++ b/domain/account/build.gradle.kts @@ -18,9 +18,9 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.kotlin.serialization) - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) + // region Test libraries + testImplementation(projects.test.core) + testImplementation(projects.test.mock) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) + // endregion } \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index e305566047..b9378ac209 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 @@ -13,18 +13,20 @@ import com.tangem.utils.extensions.addOrReplace import kotlinx.serialization.Serializable /** - * Represents a list of accounts associated with a user wallet + * Represents a list of accounts associated with a user wallet ID. * * @property userWalletId the user wallet id associated with the account list - * @property accounts a set of accounts belonging to the user wallet - * @property totalAccounts the total number of accounts + * @property accounts a list of accounts belonging to the user wallet + * @property totalAccounts the total number of accounts (including archived ones) + * @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, @@ -38,6 +40,10 @@ data class AccountList private constructor( val canAddMoreAccounts: Boolean get() = accounts.size < MAX_ACCOUNTS_COUNT + /** Returns the number of active accounts in the list */ + val activeAccounts: Int + get() = accounts.size + /** * Adds an account to the account list. * If an account with the same identifier already exists, it will be replaced. @@ -68,7 +74,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 } } @@ -81,6 +87,19 @@ data class AccountList private constructor( ) } + /** + * Flattens the list of accounts to extract all crypto currencies contained within them. + * + * @return a list of all crypto currencies from all accounts + */ + fun flattenCurrencies(): List { + return accounts.flatMap { account -> + when (account) { + is Account.CryptoPortfolio -> account.cryptoCurrencies + } + } + } + /** * Represents possible errors that can occur when creating an `AccountList` */ @@ -123,6 +142,11 @@ data class AccountList private constructor( data object DuplicateAccountNames : Error { override fun toString(): String = "$tag: Account list contains duplicate account names" } + + @Serializable + data object TotalAccountsLessThanActive : Error { + override fun toString(): String = "$tag: Total accounts cannot be less than active accounts" + } } companion object { @@ -140,7 +164,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, @@ -161,13 +185,17 @@ data class AccountList private constructor( val uniqueAccountIdsCount = accounts.map { it.accountId.value }.distinct().size ensure(accounts.size == uniqueAccountIdsCount) { Error.DuplicateAccountIds } - val customNames = accounts.mapNotNull { (it.accountName as? AccountName.Custom)?.value } + val customNames = accounts.map { (it.accountName as? AccountName.Custom)?.value } val uniqueCustomNameCount = customNames.distinct().size ensure(customNames.size == uniqueCustomNameCount) { Error.DuplicateAccountNames } + ensure(totalAccounts >= accounts.size) { + Error.TotalAccountsLessThanActive + } + AccountList( userWalletId = userWalletId, accounts = accounts, @@ -190,7 +218,7 @@ data class AccountList private constructor( ): AccountList { return AccountList( userWalletId = userWalletId, - accounts = setOf( + accounts = listOf( Account.CryptoPortfolio.createMainAccount( userWalletId = userWalletId, cryptoCurrencies = cryptoCurrencies, @@ -202,7 +230,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..f7c9a12dc5 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,8 @@ package com.tangem.domain.account.models +import arrow.core.Either +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,25 +16,39 @@ 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() } .flatten() + + fun toAccountList(): Either { + return AccountList( + userWalletId = userWalletId, + accounts = accountStatuses.map(AccountStatus::account), + totalAccounts = totalAccounts, + sortType = sortType, + groupType = groupType, + ) + } } \ No newline at end of file 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/SingleAccountListSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt index cddb1d9182..91719dd554 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt @@ -3,6 +3,8 @@ package com.tangem.domain.account.supplier import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow /** * Supplier that provides a single [AccountList] for a specific user wallet. @@ -12,4 +14,11 @@ import com.tangem.domain.core.flow.FlowCachingSupplier abstract class SingleAccountListSupplier( override val factory: SingleAccountListProducer.Factory, override val keyCreator: (SingleAccountListProducer.Params) -> String, -) : FlowCachingSupplier() \ No newline at end of file +) : FlowCachingSupplier() { + + operator fun invoke(userWalletId: UserWalletId): Flow { + return super.invoke( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } +} \ 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/ApplyAccountListSortingUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCase.kt new file mode 100644 index 0000000000..bb0714b5b1 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCase.kt @@ -0,0 +1,102 @@ +package com.tangem.domain.account.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.raise.* +import arrow.core.toNonEmptyListOrNull +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.core.utils.eitherOn +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * Use case to apply a specific sorting order to a list of accounts within a user's wallet. + * + * @property accountsCRUDRepository Repository for CRUD operations on accounts. + * @property dispatchers Coroutine dispatcher provider for managing threading. + * +[REDACTED_AUTHOR] + */ +class ApplyAccountListSortingUseCase( + private val accountsCRUDRepository: AccountsCRUDRepository, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Applies the sorting order of the provided list of accounts to the account list + * associated with the user wallet ID of the first account in the list. + * + * @param accountIds List of AccountId representing the desired order. + * @return Either an Error or Unit on successful completion. + */ + suspend operator fun invoke(accountIds: List): Either = + eitherOn(dispatcher = dispatchers.default) { + val nonEmptyIds = accountIds.toNonEmptyListOrNull() + ensureNotNull(nonEmptyIds) { Error.EmptyList } + ensure(nonEmptyIds.size > 1) { Error.UnableToSortSingleAccount } + + val userWalletId = nonEmptyIds.first().userWalletId + + ensureNotNull(userWalletId) { + Error.DataOperationFailed("UserWalletId is null in the accounts list") + } + + val accountList = getAccountList(userWalletId) + + ensure(accountList.activeAccounts > 1) { Error.UnableToSortSingleAccount } + + val positionByAccountId = nonEmptyIds.withIndex().associate { it.value to it.index } + val sortedAccounts = accountList.accounts.sortedBy { + positionByAccountId[it.accountId] ?: raise(Error.SomeAccountsNotFound) + } + + val updatedAccountList = withError( + transform = { Error.DataOperationFailed("Unable to create AccountList: $it") }, + ) { + AccountList( + userWalletId = accountList.userWalletId, + accounts = sortedAccounts, + totalAccounts = accountList.totalAccounts, + sortType = accountList.sortType, + groupType = accountList.groupType, + ) + .bind() + } + + if (updatedAccountList == accountList) { + return@eitherOn + } + + accountsCRUDRepository.saveAccounts(accountList = updatedAccountList) + } + + private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { + return catch( + block = { accountsCRUDRepository.getAccountListSync(userWalletId = userWalletId) }, + catch = { raise(Error.DataOperationFailed(cause = it)) }, + ) + .getOrElse { + raise(Error.DataOperationFailed("Account list not found for wallet $userWalletId")) + } + } + + /** + * Sealed interface representing possible errors that can occur during the application + * of account list sorting. + */ + sealed interface Error { + + data object EmptyList : Error + + data object UnableToSortSingleAccount : Error + + data object SomeAccountsNotFound : Error + + data class DataOperationFailed(val cause: Throwable) : Error { + + constructor(message: String) : this(cause = IllegalStateException(message)) + } + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt index afaa2a587e..697b9e2001 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt @@ -1,7 +1,6 @@ package com.tangem.domain.account.usecase import arrow.core.Either -import arrow.core.getOrElse import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.core.lce.Lce @@ -16,7 +15,6 @@ import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.retryWhen -import kotlinx.coroutines.launch typealias ArchivedAccountList = List @@ -37,32 +35,19 @@ class GetArchivedAccountsUseCase( * @param userWalletId the unique identifier of the user wallet */ operator fun invoke(userWalletId: UserWalletId): LceFlow = channelFlow { - val archivedAccounts = getArchivedAccounts(userWalletId = userWalletId) + send(lceLoading()) - archivedAccounts - .onRight { send(it.lceContent()) } - .onLeft { - send(lceLoading()) - - launch { - fetchArchivedAccounts(userWalletId).getOrElse { - send(it.lceError()) - } - } + fetchArchivedAccounts(userWalletId = userWalletId) + .onRight { + subscribeOnArchivedAccounts(userWalletId) + } + .onLeft { + send(it.lceError()) + return@channelFlow } - - subscribeOnArchivedAccounts(userWalletId) } .distinctUntilChanged() - private suspend fun getArchivedAccounts(userWalletId: UserWalletId): Either { - return Either.catch { - crudRepository.getArchivedAccountListSync(userWalletId = userWalletId).getOrElse { - error("Archived accounts not found for user wallet: $userWalletId") - } - } - } - private suspend fun fetchArchivedAccounts(userWalletId: UserWalletId): Either { return Either.catch { crudRepository.fetchArchivedAccounts(userWalletId) } } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt index 1a93dc67e5..86aa4adc9c 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt @@ -45,18 +45,19 @@ class IsAccountsModeEnabledUseCase( // If the wallet does not support multiple currencies, we consider its account count as 0 if (!userWallet.isMultiCurrency) return@map 0 - crudRepository.getTotalAccountsCountSync(userWalletId = userWallet.walletId).getOrZero() + crudRepository.getTotalActiveAccountsCountSync(userWalletId = userWallet.walletId).getOrZero() } .isModeEnabled() } + @Suppress("UnusedFlow") private fun getTotalAccountsCountList(userWallets: List): List> { return userWallets .map { userWallet -> // If the wallet does not support multiple currencies, we consider its account count as 0 if (!userWallet.isMultiCurrency) return@map flowOf(0) - crudRepository.getTotalAccountsCount(userWalletId = userWallet.walletId) + crudRepository.getTotalActiveAccountsCount(userWalletId = userWallet.walletId) .map { maybeCount -> maybeCount.getOrZero() } } } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt index 9b0487ea55..38fce13c3b 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,8 @@ 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.fetcher.SingleAccountListFetcher import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.account.Account @@ -17,11 +15,13 @@ import com.tangem.domain.models.wallet.UserWalletId /** * Use case for updating a crypto portfolio account. * + * @property singleAccountListFetcher the fetcher used to retrieve a single account list * @property crudRepository the repository used for performing CRUD operations on accounts * [REDACTED_AUTHOR] */ class UpdateCryptoPortfolioUseCase( + private val singleAccountListFetcher: SingleAccountListFetcher, private val crudRepository: AccountsCRUDRepository, ) { @@ -40,6 +40,8 @@ class UpdateCryptoPortfolioUseCase( ): Either = either { validate(accountName, icon) + fetchAccountList(accountId.userWalletId) + val accountList = getAccountList(userWalletId = accountId.userWalletId) val account = accountList.accounts @@ -50,8 +52,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) @@ -63,6 +69,12 @@ class UpdateCryptoPortfolioUseCase( ensure(accountName != null || icon != null) { Error.NothingToUpdate } } + private suspend fun Raise.fetchAccountList(userWalletId: UserWalletId) { + singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId)).onLeft { + raise(Error.DataOperationFailed(cause = it)) + } + } + private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, @@ -88,6 +100,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.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..34f6603024 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 @@ -2,77 +2,72 @@ package com.tangem.domain.account.models import arrow.core.Either import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth -import com.tangem.domain.account.utils.createAccount -import com.tangem.domain.account.utils.createAccounts +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.AccountName import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.ProvideTestModels +import com.tangem.test.mock.MockAccounts +import com.tangem.test.mock.MockAccounts.createAccount +import com.tangem.test.mock.MockAccounts.createAccountList +import com.tangem.test.mock.MockAccounts.createAccounts import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource /** [REDACTED_AUTHOR] */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class AccountListTest { +internal class AccountListTest { @Test fun mainAccount() { - // Arrange - val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) - - val accountList = AccountList( - userWalletId = userWalletId, - accounts = setOf(mainAccount), - totalAccounts = 1, - ) - .getOrNull()!! - // Act - val actual = accountList.mainAccount + val actual = MockAccounts.onlyMainAccount.mainAccount // Assert - val expected = mainAccount + val expected = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) Truth.assertThat(actual).isEqualTo(expected) } @Test fun canAddMoreAccounts() { // Arrange - val accountList = AccountList( - userWalletId = userWalletId, - accounts = createAccounts(userWalletId = userWalletId, count = 2), - totalAccounts = 2, - ).getOrNull()!! + val accountList = createAccountList(activeAccounts = 2) - val fullAccountList = AccountList( - userWalletId = userWalletId, - accounts = createAccounts(userWalletId = userWalletId, count = 20), - totalAccounts = 20, - ).getOrNull()!! + val fullAccountList = MockAccounts.fullAccountList // Act & Assert Truth.assertThat(accountList.canAddMoreAccounts).isTrue() Truth.assertThat(fullAccountList.canAddMoreAccounts).isFalse() } + @Test + fun activeAccounts() { + // Arrange + val accountList = createAccountList(activeAccounts = 5, totalAccounts = 10) + + // Act & Assert + val expected = 5 + Truth.assertThat(accountList.activeAccounts).isEqualTo(expected) + } + @Test fun empty() { // Act val actual = AccountList.empty(userWalletId) // Assert - val expected = AccountList( - userWalletId = userWalletId, - accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), - totalAccounts = 1, - ).getOrNull()!! + val expected = MockAccounts.onlyMainAccount + // Assert Truth.assertThat(actual).isEqualTo(expected) } @@ -80,14 +75,41 @@ class AccountListTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Create { + @Test + fun `invoke with non default sort and group types`() { + // Arrange + val accounts = createAccounts(count = 5) + val sortType = TokensSortType.BALANCE + val groupType = TokensGroupType.NETWORK + + // Act + val actual = AccountList( + userWalletId = userWalletId, + accounts = accounts, + totalAccounts = accounts.size, + sortType = sortType, + groupType = groupType, + ) + + // Assert + val expected = AccountList( + userWalletId = userWalletId, + accounts = accounts, + totalAccounts = accounts.size, + sortType = sortType, + groupType = groupType, + ) + Truth.assertThat(actual).isEqualTo(expected) + } + @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun invoke(model: CreateTestModel) { // Act val actual = AccountList( userWalletId = userWalletId, accounts = model.accounts, - totalAccounts = model.accounts.size, + totalAccounts = model.totalAccounts, ) // Assert @@ -96,17 +118,21 @@ class AccountListTest { private fun provideTestModels() = listOf( CreateTestModel( - accounts = emptySet(), + accounts = emptyList(), expected = AccountList.Error.EmptyAccountsList.left(), ), CreateTestModel( - accounts = setOf( - createAccount(userWalletId = userWalletId, derivationIndex = 1), + accounts = createAccounts(count = 21), + expected = AccountList.Error.ExceedsMaxAccountsCount.left(), + ), + CreateTestModel( + accounts = listOf( + createAccount(derivationIndex = 1), ), expected = AccountList.Error.MainAccountNotFound.left(), ), CreateTestModel( - accounts = setOf( + accounts = listOf( Account.CryptoPortfolio.createMainAccount(userWalletId), Account.CryptoPortfolio.createMainAccount(userWalletId).copy( icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), @@ -114,42 +140,44 @@ class AccountListTest { ), expected = AccountList.Error.ExceedsMaxMainAccountsCount.left(), ), - createAccounts(userWalletId = userWalletId, count = 1).let { - CreateTestModel( - accounts = it, - expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 1), - ) - }, - createAccounts(userWalletId = userWalletId, count = 20).let { - CreateTestModel( - accounts = it, - expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 20), - ) - }, CreateTestModel( - accounts = createAccounts(userWalletId = userWalletId, count = 21), - expected = AccountList.Error.ExceedsMaxAccountsCount.left(), - ), - CreateTestModel( - accounts = setOf( - createAccount(userWalletId = userWalletId, derivationIndex = 0), - createAccount(userWalletId = userWalletId, derivationIndex = 1), - createAccount(userWalletId = userWalletId, derivationIndex = 1), + accounts = listOf( + createAccount(derivationIndex = 0), + createAccount(derivationIndex = 1), + createAccount(derivationIndex = 1), ), expected = AccountList.Error.DuplicateAccountIds.left(), ), CreateTestModel( - accounts = setOf( - createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 0), - createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 1), + accounts = listOf( + createAccount(name = "Name", derivationIndex = 0), + createAccount(name = "Name", derivationIndex = 1), ), expected = AccountList.Error.DuplicateAccountNames.left(), ), + CreateTestModel( + accounts = createAccounts(count = 2), + totalAccounts = 1, + expected = AccountList.Error.TotalAccountsLessThanActive.left(), + ), + createAccountList(activeAccounts = 1).let { + CreateTestModel( + accounts = it.accounts, + expected = it.right(), + ) + }, + createAccountList(activeAccounts = 20).let { + CreateTestModel( + accounts = it.accounts, + expected = it.right(), + ) + }, ) } data class CreateTestModel( - val accounts: Set, + val accounts: List, + val totalAccounts: Int = accounts.size, val expected: Either, ) @@ -158,7 +186,7 @@ class AccountListTest { inner class Plus { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun invoke(model: PlusTestModel) { // Act val actual = model.initial.plus(other = model.toAdd) @@ -171,18 +199,18 @@ class AccountListTest { // region Add new account run { val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId) - val newAccount = createAccount(userWalletId = userWalletId, derivationIndex = 1) + val newAccount = createAccount(derivationIndex = 1) 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,27 +224,83 @@ 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, ), ) }, // endregion + // region MainAccountNotFound + run { + val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId) + val newAccount = Account.CryptoPortfolio( + accountId = mainAccount.accountId, + accountName = mainAccount.accountName, + derivationIndex = DerivationIndex(1).getOrNull()!!, + icon = mainAccount.icon, + cryptoCurrencies = mainAccount.cryptoCurrencies, + ) + + PlusTestModel( + initial = AccountList( + userWalletId = userWalletId, + accounts = listOf(mainAccount), + totalAccounts = 1, + ).getOrNull()!!, + toAdd = newAccount, + expected = AccountList.Error.MainAccountNotFound.left(), + ) + }, + // endregion + // region ExceedsMaxMainAccountsCount + run { + val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId) + val newAccount = Account.CryptoPortfolio.createMainAccount(UserWalletId("012")) + + PlusTestModel( + initial = AccountList( + userWalletId = userWalletId, + accounts = listOf(mainAccount), + totalAccounts = 1, + ).getOrNull()!!, + toAdd = newAccount, + expected = AccountList.Error.ExceedsMaxMainAccountsCount.left(), + ) + }, + // endregion + // region DuplicateAccountNames + run { + val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId) + val newAccount = createAccount(derivationIndex = 1).copy(accountName = mainAccount.accountName) + + PlusTestModel( + initial = AccountList( + userWalletId = userWalletId, + accounts = listOf(mainAccount), + totalAccounts = 1, + ).getOrNull()!!, + toAdd = newAccount, + expected = AccountList.Error.DuplicateAccountNames.left(), + ) + }, + // endregion + // region ExceedsMaxAccountsCount PlusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = createAccounts(userWalletId = userWalletId, count = 20), + accounts = createAccounts(count = 20), totalAccounts = 20, ).getOrNull()!!, - toAdd = createAccount(userWalletId = userWalletId, derivationIndex = 21), + toAdd = createAccount(derivationIndex = 21), expected = AccountList.Error.ExceedsMaxAccountsCount.left(), ), + // endregion ) } @@ -231,7 +315,7 @@ class AccountListTest { inner class Minus { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun invoke(model: MinusTestModel) { // Act val actual = model.initial.minus(model.toRemove) @@ -244,18 +328,18 @@ class AccountListTest { // region Remove existing account run { val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId) - val secondaryAccount = createAccount(userWalletId = userWalletId, derivationIndex = 2) + val secondaryAccount = createAccount(derivationIndex = 2) 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, ), ) @@ -264,20 +348,18 @@ class AccountListTest { // region Remove unexisting account run { val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId) - val notInList = createAccount(userWalletId = userWalletId, derivationIndex = 3) + val notInList = createAccount(derivationIndex = 3) + + val accountList = AccountList( + userWalletId = userWalletId, + accounts = listOf(mainAccount), + totalAccounts = 1, + ) MinusTestModel( - initial = AccountList( - userWalletId = userWalletId, - accounts = setOf(mainAccount), - totalAccounts = 1, - ).getOrNull()!!, + initial = accountList.getOrNull()!!, toRemove = notInList, - expected = AccountList( - userWalletId = userWalletId, - accounts = setOf(mainAccount), - totalAccounts = 1, - ), + expected = accountList, ) }, // endregion @@ -288,7 +370,7 @@ class AccountListTest { MinusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount), + accounts = listOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toRemove = mainAccount, @@ -299,12 +381,12 @@ class AccountListTest { // region MainAccountNotFound run { val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId) - val secondaryAccount = createAccount(userWalletId = userWalletId, derivationIndex = 2) + val secondaryAccount = createAccount(derivationIndex = 2) MinusTestModel( initial = AccountList( userWalletId = userWalletId, - accounts = setOf(mainAccount, secondaryAccount), + accounts = listOf(mainAccount, secondaryAccount), totalAccounts = 2, ).getOrNull()!!, toRemove = mainAccount, @@ -323,6 +405,6 @@ class AccountListTest { private companion object { - val userWalletId = UserWalletId("011") + val userWalletId = MockAccounts.userWalletId } } \ No newline at end of file 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..d9756586c3 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 @@ -10,11 +10,8 @@ import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase.Error -import com.tangem.domain.account.utils.createAccount -import com.tangem.domain.account.utils.createAccounts -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.mock.MockAccounts +import com.tangem.test.mock.MockAccounts.createAccount import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -42,7 +39,7 @@ class AddCryptoPortfolioUseCaseTest { @Test fun `invoke should add new crypto portfolio account to existing list`() = runTest { // Arrange - val newAccount = createNewAccount() + val newAccount = createAccount(derivationIndex = 1) val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! @@ -69,6 +66,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) crudRepository.saveAccounts(updatedAccountList) mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex) } @@ -77,7 +75,7 @@ class AddCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if fetch is failed`() = runTest { // Arrange - val newAccount = createNewAccount() + val newAccount = createAccount(derivationIndex = 1) val exception = Exception("Fetch error") coEvery { @@ -112,7 +110,7 @@ class AddCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if account list none exists`() = runTest { // Arrange - val newAccount = createNewAccount() + val newAccount = createAccount(derivationIndex = 1) coEvery { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) @@ -146,13 +144,8 @@ class AddCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if account list requirements not met`() = runTest { // Arrange - val accountList = AccountList( - userWalletId = userWalletId, - accounts = createAccounts(userWalletId = userWalletId, count = 20), - totalAccounts = 20, - ).getOrNull()!! - - val newAccount = createNewAccount(derivationIndex = 21) + val accountList = MockAccounts.fullAccountList + val newAccount = createAccount(derivationIndex = 21) coEvery { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) @@ -177,6 +170,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) } coVerify(inverse = true) { @@ -188,7 +182,7 @@ class AddCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if getAccounts throws exception`() = runTest { // Arrange - val newAccount = createNewAccount() + val newAccount = createAccount(derivationIndex = 1) val exception = IllegalStateException("Test error") coEvery { @@ -222,7 +216,7 @@ class AddCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange - val newAccount = createNewAccount() + val newAccount = createAccount(derivationIndex = 1) val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! @@ -249,6 +243,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) crudRepository.saveAccounts(updatedAccountList) } @@ -260,7 +255,7 @@ class AddCryptoPortfolioUseCaseTest { @Test fun `invoke should return new account if migrate returns error`() = runTest { // Arrange - val newAccount = createNewAccount() + val newAccount = createAccount(derivationIndex = 1) val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! @@ -288,6 +283,7 @@ class AddCryptoPortfolioUseCaseTest { coVerifySequence { singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId)) crudRepository.getAccountListSync(userWalletId) + crudRepository.checkDefaultAccountName(accountList, newAccount.accountName) crudRepository.saveAccounts(updatedAccountList) mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex) } @@ -295,15 +291,6 @@ class AddCryptoPortfolioUseCaseTest { private companion object { - val userWalletId = UserWalletId("011") - - fun createNewAccount(derivationIndex: Int = 1): Account.CryptoPortfolio { - return createAccount( - userWalletId = userWalletId, - name = "New Account", - icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), - derivationIndex = derivationIndex, - ) - } + val userWalletId = MockAccounts.userWalletId } } \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCaseTest.kt new file mode 100644 index 0000000000..2d19983849 --- /dev/null +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCaseTest.kt @@ -0,0 +1,187 @@ +package com.tangem.domain.account.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.account.usecase.ApplyAccountListSortingUseCase.Error +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.mock.MockAccounts +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ApplyAccountListSortingUseCaseTest { + + private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) + private val useCase = ApplyAccountListSortingUseCase( + accountsCRUDRepository = accountsCRUDRepository, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + + @AfterEach + fun tearDown() { + clearMocks(accountsCRUDRepository) + } + + @Test + fun `invoke returns EmptyList when accountIds is empty`() = runTest { + // Act + val actual = useCase(accountIds = emptyList()) + + // Assert + val expected = Error.EmptyList.left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerify(inverse = true) { + accountsCRUDRepository.getAccountListSync(any()) + accountsCRUDRepository.saveAccounts(any()) + } + } + + @Test + fun `invoke returns UnableToSortSingleAccount when accountIds contain one ID`() = runTest { + // Act + val actual = useCase(accountIds = listOf(mockk(relaxed = true))) + + // Assert + val expected = Error.UnableToSortSingleAccount.left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerify(inverse = true) { + accountsCRUDRepository.getAccountListSync(any()) + accountsCRUDRepository.saveAccounts(any()) + } + } + + @Test + fun `invoke returns DataOperationFailed when getAccountList returns error`() = runTest { + // Arrange + val accountIds = defaultAccountList.toAccountIds() + + val exception = Exception("Test error") + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } throws exception + + // Act + val actual = useCase(accountIds) + + // Assert + val expected = Error.DataOperationFailed(exception).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) } + coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) } + } + + @Test + fun `invoke returns UnableToSortSingleAccount when saved accountList contain one account`() = runTest { + // Arrange + val accountIds = defaultAccountList.toAccountIds() + + coEvery { + accountsCRUDRepository.getAccountListSync(userWalletId) + } returns AccountList.empty(userWalletId).some() + + // Act + val actual = useCase(accountIds) + + // Assert + val expected = Error.UnableToSortSingleAccount.left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) } + coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) } + } + + @Test + fun `invoke returns SomeAccountsNotFound when saved AccountList doesn't contain unknown ID`() = runTest { + // Arrange + val unknownAccountId = AccountId.forMainCryptoPortfolio( + userWalletId = UserWalletId("012"), + ) + + val accountList = defaultAccountList + val accountIds = listOf(accountList.mainAccount.accountId, unknownAccountId) + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + // Act + val actual = useCase(accountIds) + + // Assert + val expected = Error.SomeAccountsNotFound.left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) } + coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) } + } + + @Test + fun `invoke returns Right without accounts saving when nothing to change`() = runTest { + // Arrange + val accountList = defaultAccountList + val accountIds = accountList.toAccountIds() + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + // Act + val actual = useCase(accountIds) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) } + coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) } + } + + @Test + fun `invoke returns Right`() = runTest { + // Arrange + val accountList = defaultAccountList + val accountIds = accountList.toAccountIds().reversed() + + val updatedAccountList = AccountList( + userWalletId = accountList.userWalletId, + accounts = accountList.accounts.reversed(), + totalAccounts = accountList.totalAccounts, + sortType = accountList.sortType, + groupType = accountList.groupType, + ).getOrNull()!! + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + // Act + val actual = useCase(accountIds) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccounts(updatedAccountList) + } + } + + private fun AccountList.toAccountIds(): List { + return this.accounts.map { it.accountId } + } + + private companion object { + + val defaultAccountList = MockAccounts.createAccountList(activeAccounts = 2) + } +} \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt deleted file mode 100644 index b23bd9bfe0..0000000000 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.tangem.domain.account.usecase - -import arrow.core.None -import arrow.core.left -import arrow.core.right -import arrow.core.toOption -import com.google.common.truth.Truth -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.repository.AccountsCRUDRepository -import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase.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.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 - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ArchiveCryptoPortfolioUseCaseTest { - - private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) - private val useCase = ArchiveCryptoPortfolioUseCase(crudRepository) - - @BeforeEach - fun resetMocks() { - clearMocks(crudRepository) - } - - @Test - fun `invoke should archive existing crypto portfolio account`() = runTest { - // Arrange - val account = createAccount(userWalletId) - val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! - val accountId = account.accountId - - val updatedAccountList = (accountList - account).getOrNull()!! - - coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() - - // Act - val actual = useCase(accountId) - - // Assert - val expected = Unit.right() - Truth.assertThat(actual).isEqualTo(expected) - - coVerifyOrder { - crudRepository.getAccountListSync(userWalletId) - crudRepository.saveAccounts(updatedAccountList) - } - } - - @Test - fun `invoke should return error if getAccounts returns None`() = runTest { - // Arrange - val accountId = AccountId.forCryptoPortfolio( - userWalletId = userWalletId, - derivationIndex = DerivationIndex.Main, - ) - - coEvery { crudRepository.getAccountListSync(userWalletId) } returns None - - // Act - val actual = useCase(accountId) - - // Assert - val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() - Truth.assertThat(actual).isEqualTo(expected) - - coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } - coVerify(inverse = true) { crudRepository.saveAccounts(any()) } - } - - @Test - fun `invoke should return error if getAccounts throws exception`() = runTest { - // Arrange - val accountId = AccountId.forCryptoPortfolio( - userWalletId = userWalletId, - derivationIndex = DerivationIndex.Main, - ) - - val exception = IllegalStateException("Test error") - - coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception - - // Act - val actual = useCase(accountId) - - // Assert - val expected = Error.DataOperationFailed(exception).left() - Truth.assertThat(actual).isEqualTo(expected) - - coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } - coVerify(inverse = true) { crudRepository.saveAccounts(any()) } - } - - @Test - fun `invoke should return error if account not found`() = runTest { - // Arrange - val accountList = AccountList.empty(userWalletId) - val accountId = AccountId.forCryptoPortfolio( - userWalletId = userWalletId, - derivationIndex = DerivationIndex(1).getOrNull()!!, - ) - - coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() - - // Act - val actual = useCase(accountId) - - // Assert - val expected = Error.CriticalTechError.AccountNotFound(accountId).left() - Truth.assertThat(actual).isEqualTo(expected) - - coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } - coVerify(inverse = true) { crudRepository.saveAccounts(any()) } - } - - @Test - fun `invoke should return error if saveAccounts throws exception`() = runTest { - // Arrange - val account = createAccount(userWalletId) - val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! - val accountId = account.accountId - - val updatedAccountList = (accountList - account).getOrNull()!! - - val exception = IllegalStateException("Save failed") - - coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception - - // Act - val actual = useCase(accountId) - - // Assert - val expected = Error.DataOperationFailed(exception).left() - Truth.assertThat(actual).isEqualTo(expected) - - coVerifyOrder { - crudRepository.getAccountListSync(userWalletId) - crudRepository.saveAccounts(updatedAccountList) - } - } - - private companion object { - val userWalletId = UserWalletId("011") - } -} \ No newline at end of file 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..5fe1763d23 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 @@ -9,20 +7,16 @@ 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.models.wallet.UserWalletId +import com.tangem.test.core.getEmittedValues 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 -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest 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 +37,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 +59,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,24 +76,9 @@ class GetArchivedAccountsUseCaseTest { lceLoading(), exception.lceError(), ) - Truth.assertThat(actual).isEqualTo(expected) - coVerify(exactly = 1) { - crudRepository.getArchivedAccountListSync(userWalletId) - crudRepository.fetchArchivedAccounts(userWalletId) - crudRepository.getArchivedAccounts(userWalletId) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun TestScope.getEmittedValues(flow: Flow): List { - val values = mutableListOf() - - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - flow.toList(values) - } - - return values + coVerifyOrder { crudRepository.fetchArchivedAccounts(userWalletId) } + coVerify(inverse = true) { crudRepository.getArchivedAccounts(any()) } } } \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt index 06b7c267d0..e5d8cfedc1 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +@Suppress("UnusedFlow") @TestInstance(TestInstance.Lifecycle.PER_CLASS) class IsAccountsModeEnabledUseCaseTest { @@ -68,7 +69,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWallets() } - verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + verify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCount(any()) } } @Test @@ -90,7 +91,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWallets() } - verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + verify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCount(any()) } } @Test @@ -100,7 +101,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) - every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(2.some()) + every { accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } returns flowOf(2.some()) // Act val actual = useCase.invoke().first() @@ -111,7 +112,7 @@ class IsAccountsModeEnabledUseCaseTest { verifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWallets() - accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } } @@ -122,7 +123,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) - every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(none()) + every { accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } returns flowOf(none()) // Act val actual = useCase.invoke().first() @@ -133,7 +134,7 @@ class IsAccountsModeEnabledUseCaseTest { verifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWallets() - accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } } @@ -145,7 +146,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet1, wallet2)) - every { accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) } returns flowOf(2.some()) + every { accountsCRUDRepository.getTotalActiveAccountsCount(wallet2.walletId) } returns flowOf(2.some()) // Act val actual = useCase.invoke().first() @@ -156,10 +157,10 @@ class IsAccountsModeEnabledUseCaseTest { verifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWallets() - accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) + accountsCRUDRepository.getTotalActiveAccountsCount(wallet2.walletId) } - verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(wallet1.walletId) } + verify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCount(wallet1.walletId) } } } @@ -199,7 +200,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWalletsSync() } - coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + coVerify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCountSync(any()) } } @Test @@ -221,7 +222,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWalletsSync() } - coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + coVerify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCountSync(any()) } } @Test @@ -231,7 +232,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) - coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns 2.some() + coEvery { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } returns 2.some() // Act val actual = useCase.invokeSync() @@ -242,7 +243,7 @@ class IsAccountsModeEnabledUseCaseTest { coVerifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWalletsSync() - accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } } @@ -253,7 +254,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) - coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns none() + coEvery { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } returns none() // Act val actual = useCase.invokeSync() @@ -264,7 +265,7 @@ class IsAccountsModeEnabledUseCaseTest { coVerifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWalletsSync() - accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } } @@ -276,7 +277,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet1, wallet2) - coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) } returns 2.some() + coEvery { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet2.walletId) } returns 2.some() // Act val actual = useCase.invokeSync() @@ -287,10 +288,10 @@ class IsAccountsModeEnabledUseCaseTest { coVerifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWalletsSync() - accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) + accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet2.walletId) } - coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(wallet1.walletId) } + coVerify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet1.walletId) } } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt index 724429e3d3..d06a13b898 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt @@ -5,6 +5,7 @@ import arrow.core.left import arrow.core.right import arrow.core.toOption import com.google.common.truth.Truth +import com.tangem.domain.account.fetcher.SingleAccountListFetcher import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase.Error @@ -25,8 +26,13 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class UpdateCryptoPortfolioUseCaseTest { + private val singleAccountListFetcher: SingleAccountListFetcher = mockk() private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) - private val useCase = UpdateCryptoPortfolioUseCase(crudRepository = crudRepository) + + private val useCase = UpdateCryptoPortfolioUseCase( + singleAccountListFetcher = singleAccountListFetcher, + crudRepository = crudRepository, + ) @BeforeEach fun resetMocks() { @@ -43,6 +49,9 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act @@ -53,6 +62,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { + singleAccountListFetcher(params) crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } @@ -71,6 +81,9 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act @@ -81,6 +94,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { + singleAccountListFetcher(params) crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } @@ -100,6 +114,9 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName, icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act @@ -110,6 +127,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { + singleAccountListFetcher(params) crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } @@ -121,6 +139,9 @@ class UpdateCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act @@ -137,7 +158,7 @@ class UpdateCryptoPortfolioUseCaseTest { } @Test - fun `invoke if getAccounts throws exception`() = runTest { + fun `invoke if singleAccountListFetcher throws exception`() = runTest { // Arrange val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId @@ -145,7 +166,35 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! val exception = IllegalStateException("Test exception") + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns exception.left() + // Act + val actual = useCase(accountId = accountId, accountName = newAccountName) + + // Assert + val expected = Error.DataOperationFailed(cause = exception).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { singleAccountListFetcher(params) } + coVerify(inverse = true) { + crudRepository.getAccountListSync(userWalletId = any()) + crudRepository.saveAccounts(accountList = any()) + } + } + + @Test + fun `invoke if getAccounts throws exception`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId) + val accountId = accountList.mainAccount.accountId + + val newAccountName = AccountName("New name").getOrNull()!! + + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + + val exception = IllegalStateException("Test exception") coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } throws exception // Act @@ -155,7 +204,10 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(cause = exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } + coVerifyOrder { + singleAccountListFetcher(params) + crudRepository.getAccountListSync(userWalletId = userWalletId) + } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -170,6 +222,9 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList // Act @@ -180,7 +235,10 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual.cause).isInstanceOf(expected::class.java) Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message) - coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } + coVerifyOrder { + singleAccountListFetcher(params) + crudRepository.getAccountListSync(userWalletId = userWalletId) + } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -195,6 +253,9 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act @@ -205,7 +266,10 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual.cause).isInstanceOf(expected::class.java) Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message) - coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } + coVerifyOrder { + singleAccountListFetcher(params) + crudRepository.getAccountListSync(userWalletId = userWalletId) + } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -219,6 +283,9 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! + val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { singleAccountListFetcher(params) } returns Unit.right() + val exception = IllegalStateException("Save failed") coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() @@ -232,6 +299,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { + singleAccountListFetcher(params) crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } 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 deleted file mode 100644 index 9f452c4145..0000000000 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/utils/AccountExt.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.domain.account.utils - -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 { - add(Account.CryptoPortfolio.createMainAccount(userWalletId)) - - repeat(count - 1) { - val account = createAccount( - userWalletId = userWalletId, - name = "Test Account ${it + 1}", - derivationIndex = it + 1, - ) - - add(account) - } - } -} - -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(), - ) -} \ No newline at end of file diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 8e2f490e75..7e7cfa8521 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -19,9 +19,11 @@ 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) + api(projects.domain.referral) api(projects.domain.staking) api(projects.domain.tokens) api(projects.domain.wallets) @@ -40,11 +42,8 @@ dependencies { kapt(deps.hilt.kapt) // end - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) - testImplementation(tangemDeps.blockchain) testImplementation(projects.common.test) + testImplementation(projects.test.core) + testImplementation(projects.test.mock) } \ No newline at end of file diff --git a/domain/account/status/detekt-baseline-debug.xml b/domain/account/status/detekt-baseline-debug.xml new file mode 100644 index 0000000000..1f66781ca0 --- /dev/null +++ b/domain/account/status/detekt-baseline-debug.xml @@ -0,0 +1,17 @@ + + + + + MultilineLambdaItParameter:AccountCryptoCurrencyStatusFinder.kt$AccountCryptoCurrencyStatusFinder${ val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@filter false cryptoPortfolio.derivationIndex.value in possibleAccountIndexes } + MultilineLambdaItParameter:AccountCryptoCurrencyStatusFinder.kt$AccountCryptoCurrencyStatusFinder${ val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@firstOrNull false cryptoPortfolio.derivationIndex.value == possibleAccountIndex } + MultilineLambdaItParameter:AccountCryptoCurrencyStatusFinder.kt$AccountCryptoCurrencyStatusFinder${ val currency = it.currency val isContractAddressMatch = contractAddress == null || currency.id.contractAddress.equals(contractAddress, ignoreCase = true) currency.network.rawId == networkId.rawId.value && currency.network.derivationPath.value == derivationPath.value && isContractAddressMatch } + MultilineLambdaItParameter:ApplyTokenListSortingUseCaseV2.kt$ApplyTokenListSortingUseCaseV2${ errors[account.accountId] = it return@map account } + MultilineLambdaItParameter:DefaultMultiAccountStatusListProducer.kt$DefaultMultiAccountStatusListProducer${ singleAccountStatusListSupplier( params = SingleAccountStatusListProducer.Params(it.walletId), ) } + MultilineLambdaItParameter:ManageCryptoCurrenciesUseCase.kt$ManageCryptoCurrenciesUseCase${ ExpressAsset.ID( networkId = it.network.backendId, contractAddress = (it as? CryptoCurrency.Token)?.contractAddress, ) } + MultilineLambdaItParameter:ManageCryptoCurrenciesUseCase.kt$ManageCryptoCurrenciesUseCase${ it.network.backendId == networkId && !it.isCustom && it.contractAddress.equals(contractAddress, true) } + UnnecessaryAbstractClass:MultiAccountStatusListSupplier.kt$MultiAccountStatusListSupplier$MultiAccountStatusListSupplier + UnnecessaryAbstractClass:SingleAccountStatusListSupplier.kt$SingleAccountStatusListSupplier$SingleAccountStatusListSupplier + UnnecessaryAbstractClass:SingleAccountStatusSupplier.kt$SingleAccountStatusSupplier$SingleAccountStatusSupplier + UnnecessaryLet:RecoverCryptoPortfolioUseCase.kt$RecoverCryptoPortfolioUseCase$let(AccountName::invoke) + + diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListSupplierModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListSupplierModule.kt index 51b10dc806..b075cd11bc 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListSupplierModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusListSupplierModule.kt @@ -21,7 +21,7 @@ internal object AccountStatusListSupplierModule { ): SingleAccountStatusListSupplier { return object : SingleAccountStatusListSupplier( factory = factory, - keyCreator = { "account_status_list_${it.userWalletId}" }, + keyCreator = { "account_status_list_${it.userWalletId.stringValue}" }, ) {} } 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..70d95e70cd 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,30 +90,69 @@ internal object AccountStatusUseCaseModule { @Provides @Singleton - fun provideSaveCryptoCurrenciesUseCase( - singleAccountListSupplier: SingleAccountListSupplier, + fun provideManageCryptoCurrenciesUseCase( + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, accountsCRUDRepository: AccountsCRUDRepository, currenciesRepository: CurrenciesRepository, derivationsRepository: DerivationsRepository, + cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, + stakingIdFactory: StakingIdFactory, + networksCleaner: NetworksCleaner, + stakingCleaner: StakingCleaner, + expressServiceFetcher: ExpressServiceFetcher, + dispatchers: CoroutineDispatcherProvider, + ): ManageCryptoCurrenciesUseCase { + return ManageCryptoCurrenciesUseCase( + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + 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, - networksCleaner: NetworksCleaner, - stakingCleaner: StakingCleaner, dispatchers: CoroutineDispatcherProvider, - ): SaveCryptoCurrenciesUseCase { - return SaveCryptoCurrenciesUseCase( - singleAccountListSupplier = singleAccountListSupplier, + ): CryptoCurrencyBalanceFetcher { + return CryptoCurrencyBalanceFetcher( accountsCRUDRepository = accountsCRUDRepository, - currenciesRepository = currenciesRepository, - derivationsRepository = derivationsRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiYieldBalanceFetcher = multiYieldBalanceFetcher, stakingIdFactory = stakingIdFactory, - networksCleaner = networksCleaner, - stakingCleaner = stakingCleaner, + parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), + ) + } + + @Provides + @Singleton + fun provideToggleTokenListSortingUseCaseV2( + dispatchers: CoroutineDispatcherProvider, + ): ToggleTokenListSortingUseCaseV2 { + return ToggleTokenListSortingUseCaseV2( + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideToggleTokenListGroupingUseCaseV2( + dispatchers: CoroutineDispatcherProvider, + ): ToggleTokenListGroupingUseCaseV2 { + return ToggleTokenListGroupingUseCaseV2( dispatchers = dispatchers, ) } 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..8b3efcfba9 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 @@ -2,20 +2,24 @@ package com.tangem.domain.account.status.producer import arrow.core.Option import arrow.core.none +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 import com.tangem.domain.models.TokensGroupType import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.quote.PriceChange import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.tokens.operations.PriceChangeCalculator import com.tangem.domain.tokens.operations.TokenListFactory import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator @@ -24,13 +28,16 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import java.math.BigDecimal +import kotlin.time.Duration.Companion.milliseconds /** * 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,8 +47,9 @@ 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 networksRepository: NetworksRepository, private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory, private val dispatchers: CoroutineDispatcherProvider, ) : SingleAccountStatusListProducer { @@ -49,20 +57,35 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo override val fallback: Option = none() override fun produce(): Flow { - val accountListFlow = singleAccountListSupplier( - params = SingleAccountListProducer.Params(params.userWalletId), - ) + return singleAccountListSupplier(userWalletId = params.userWalletId).flatMapLatest { accountList -> + val accountStatusFlows = createAccountStatusFlows(accountList) - return accountListFlow.flatMapLatest { accountList -> - val accountStatusFlows = accountList.accounts - .filterIsInstance() - .map { account -> + combine(accountStatusFlows) { accountStatuses -> + val balances = accountStatuses.flattenTotalFiatBalance() + + AccountStatusList( + userWalletId = accountList.userWalletId, + accountStatuses = accountStatuses.toList(), + totalAccounts = accountList.totalAccounts, + totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), + sortType = accountList.sortType, + groupType = accountList.groupType, + ) + } + .onStartCheckCachedNetworks(accountList) + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + private fun createAccountStatusFlows(accountList: AccountList): List> { + return accountList.accounts.map { account -> + when (account) { + is Account.CryptoPortfolio -> { if (account.cryptoCurrencies.isEmpty()) { createEmptyAccountStatusFlow(account) } else { - val userWallet = userWalletsListRepository.userWalletsSync().first { - it.walletId == params.userWalletId - } + val userWallet = accountsCRUDRepository.getUserWallet(userWalletId = params.userWalletId) getAccountStatusFlow( userWallet = userWallet, @@ -71,20 +94,10 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo sortType = accountList.sortType, ) } + .distinctUntilChanged() } - - combine(accountStatusFlows) { accountStatuses -> - val balances = accountStatuses.map { it.tokenList.totalFiatBalance } - - AccountStatusList( - userWalletId = accountList.userWalletId, - accountStatuses = accountStatuses.toSet(), - totalAccounts = accountList.totalAccounts, - totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), - ) } } - .flowOn(dispatchers.default) } private fun createEmptyAccountStatusFlow(account: Account.CryptoPortfolio): Flow { @@ -106,25 +119,87 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo groupType: TokensGroupType, sortType: TokensSortType, ): Flow { + val statusesFlows = getCryptoCurrencyStatusesFlow(userWallet, account) + + return statusesFlows + .map { statusList -> + AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenListFactory.create( + statuses = statusList, + groupType = groupType, + sortType = sortType, + ), + priceChangeLce = PriceChangeCalculator.calculate(statuses = statusList), + ) + } + .distinctUntilChanged() + } + + @OptIn(FlowPreview::class) + private fun getCryptoCurrencyStatusesFlow( + userWallet: UserWallet, + account: Account.CryptoPortfolio, + ): Flow> { val statusesFlows = account.cryptoCurrencies.map { currency -> cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = currency) + .onStart { emit(CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)) } + .distinctUntilChanged() } - return combine(statusesFlows) { statuses -> - val statusList = statuses.toList() + return combine(statusesFlows) { it.toList() } + .distinctUntilChanged() + .debounce(50.milliseconds) + } - AccountStatus.CryptoPortfolio( - account = account, - tokenList = TokenListFactory.create( - statuses = statusList, - groupType = groupType, - sortType = sortType, - ), - priceChangeLce = PriceChangeCalculator.calculate(statuses = statusList), - ) + private fun Array.flattenTotalFiatBalance(): List { + return map { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance + } } } + private fun Flow.onStartCheckCachedNetworks(accountList: AccountList): Flow { + return onStart { + val hasCachedNetworks = networksRepository.hasCachedStatuses(userWalletId = accountList.userWalletId) + + if (hasCachedNetworks) return@onStart + + val loading = createLoadingAccountStatusList(accountList) + emit(loading) + } + } + + private fun createLoadingAccountStatusList(accountList: AccountList): AccountStatusList { + return AccountStatusList( + userWalletId = accountList.userWalletId, + accountStatuses = accountList.accounts.map { account -> + when (account) { + is Account.CryptoPortfolio -> { + val currencyStatuses = account.cryptoCurrencies.map { + CryptoCurrencyStatus(currency = it, value = CryptoCurrencyStatus.Loading) + } + + AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenListFactory.create( + statuses = currencyStatuses, + groupType = accountList.groupType, + sortType = accountList.sortType, + ), + priceChangeLce = lceLoading(), + ) + } + } + }, + totalAccounts = accountList.totalAccounts, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = accountList.sortType, + groupType = accountList.groupType, + ) + } + @AssistedFactory interface Factory : SingleAccountStatusListProducer.Factory { override fun create(params: SingleAccountStatusListProducer.Params): DefaultSingleAccountStatusListProducer 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/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt similarity index 51% rename from domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt rename to domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt index 75056d6eb0..7a0c57b6ac 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt @@ -1,31 +1,48 @@ -package com.tangem.domain.account.usecase +package com.tangem.domain.account.status.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.* import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.models.account.AccountId +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.referral.ReferralRepository /** * Use case for archiving a crypto portfolio. * This class provides functionality to archive a specific account within a user's crypto portfolio. * It ensures that the account exists and meets the necessary requirements before performing the operation. * + * @property singleAccountStatusListSupplier supplier to get the list of account statuses * @property crudRepository repository for performing CRUD operations on accounts + * @property referralRepository repository for handling referral status checks * [REDACTED_AUTHOR] */ class ArchiveCryptoPortfolioUseCase( + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val crudRepository: AccountsCRUDRepository, + private val referralRepository: ReferralRepository, ) { /** Archives the specified account by its [accountId] */ suspend operator fun invoke(accountId: AccountId): Either = either { - val accountList = getAccountList(userWalletId = accountId.userWalletId) + val accountStatusList = getAccountStatusList(userWalletId = accountId.userWalletId) + val accountList = accountStatusList.toAccountList().getOrElse { + raise(Error.CriticalTechError.AccountListRequirementsNotMet(cause = it)) + } + + ensure(accountList.accounts.any { it.accountId == accountId }) { + Error.CriticalTechError.AccountNotFound(accountId = accountId) + } + + checkReferralStatus(accountStatusList = accountStatusList, accountId = accountId) val archivingAccount = accountList.accounts .firstOrNull { it.accountId == accountId } @@ -38,16 +55,48 @@ class ArchiveCryptoPortfolioUseCase( saveAccounts(updatedAccounts) } - private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { - return catch( - block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, + private suspend fun Raise.checkReferralStatus(accountStatusList: AccountStatusList, accountId: AccountId) { + val referralStatus = catch( + block = { referralRepository.getReferralStatus(userWalletId = accountStatusList.userWalletId.stringValue) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) - .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } + + val referralToken = referralStatus.token + val address = referralStatus.address + + if (!referralStatus.isActive || referralToken == null || address == null) return + + val account = accountStatusList.accountStatuses + .asSequence() + .filterIsInstance() + .firstOrNull { it.accountId == accountId } + + ensureNotNull(account) { + Error.CriticalTechError.AccountNotFound(accountId = accountId) + } + + val statuses = account.flattenCurrencies() + + val hasNotReferralToken = statuses.none { status -> + val currency = status.currency + + currency.network.backendId == referralToken.networkId && + (currency as? CryptoCurrency.Token)?.contractAddress == referralToken.contractAddress && + status.value.networkAddress?.availableAddresses?.any { it.value == address } == true + } + + ensure(hasNotReferralToken) { Error.ActiveReferralStatus } + } + + private suspend fun Raise.getAccountStatusList(userWalletId: UserWalletId): AccountStatusList { + return singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(userWalletId = userWalletId), + ) + ?: raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } private suspend fun Raise.saveAccounts(accountList: AccountList) { - catch( + arrow.core.raise.catch( block = { crudRepository.saveAccounts(accountList) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) @@ -58,9 +107,14 @@ class ArchiveCryptoPortfolioUseCase( */ sealed interface Error { + val tag: String + get() = this.javaClass.simpleName + + data object ActiveReferralStatus : Error + /** Error indicating that a data operation failed */ data class DataOperationFailed(val cause: Throwable) : Error { - override fun toString(): String = "$this: Data operation failed: ${cause.message ?: "Unknown error"}" + override fun toString(): String = "$tag: Data operation failed: ${cause.message ?: "Unknown error"}" } /** 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..31de28f37e 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() @@ -82,6 +82,7 @@ class GetAccountCurrencyByAddressUseCase( for (id in userWalletIds) { val networkStatus = multiNetworkStatusSupplier.getSyncOrNull( params = MultiNetworkStatusProducer.Params(userWalletId = id), + timeMillis = 1000L, ) ?.firstOrNull { it.getAddress() == address } 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..60295d2709 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,45 @@ 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() + } + + suspend fun invokeSync( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): Option { + val accountStatusList = getAccountStatusListSync(userWalletId) ?: return none() + + return AccountCryptoCurrencyStatusFinder( + accountStatusList = accountStatusList, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, + ) + .toOption() + } + /** * Retrieves the status of a specific cryptocurrency for a given user wallet as a [Flow]. * @@ -91,6 +125,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 60% 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..9277ab4837 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 @@ -3,20 +3,22 @@ package com.tangem.domain.account.status.usecase import arrow.core.Either 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.supplier.SingleAccountListSupplier +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.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.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.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 @@ -27,31 +29,32 @@ import timber.log.Timber /** * Use case for saving crypto currencies to a specific account. * - * @property singleAccountListSupplier Supplier to get account details. + * @property singleAccountStatusListSupplier Supplier for fetching the status of a single account. + * @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( - private val singleAccountListSupplier: SingleAccountListSupplier, +class ManageCryptoCurrenciesUseCase( + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, 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, ) { @@ -75,20 +78,37 @@ class SaveCryptoCurrenciesUseCase( val userWalletId = accountId.userWalletId withContext(NonCancellable) { - val account = getAccount(accountId = accountId) + val accountStatus = getAccountStatus(accountId = accountId) - val modifiedCurrencyList = account.cryptoCurrencies.modify(add = add, remove = remove) + val modifiedCurrencyList = accountStatus.tokenList.flattenCurrencies() + .modify(add = add, remove = remove) saveAccount( - account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()), + account = accountStatus.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() + } } } @@ -100,10 +120,10 @@ class SaveCryptoCurrenciesUseCase( val userWalletId = accountId.userWalletId withContext(NonCancellable) { - val account = getAccount(accountId = accountId) + val accountStatus = getAccountStatus(accountId = accountId) - val foundToken = account.cryptoCurrencies - .filterIsInstance() + val foundToken = accountStatus.tokenList.flattenCurrencies() + .mapNotNull { it.currency as? CryptoCurrency.Token } .firstOrNull { it.network.backendId == networkId && !it.isCustom && @@ -114,32 +134,45 @@ class SaveCryptoCurrenciesUseCase( val tokenToAdd = findToken(userWalletId, contractAddress, networkId) - refreshBalances(userWalletId = userWalletId, currencies = listOf(tokenToAdd)).joinAll() + val modifiedCurrencyList = accountStatus.tokenList.flattenCurrencies() + .modify(add = listOf(tokenToAdd)) + + saveAccount(account = accountStatus.account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet())) + + parallelUpdatingScope.launch { + cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = listOf(tokenToAdd)) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) + } tokenToAdd } } - private suspend fun Raise.getAccount(accountId: AccountId): Account.CryptoPortfolio { - val accountList = singleAccountListSupplier.getSyncOrNull( - params = SingleAccountListProducer.Params(userWalletId = accountId.userWalletId), + private suspend fun Raise.getAccountStatus(accountId: AccountId): AccountStatus.CryptoPortfolio { + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull( + params = SingleAccountStatusListProducer.Params(userWalletId = accountId.userWalletId), ) ?: raise(IllegalStateException("No accounts for wallet ${accountId.userWalletId}")) - return accountList.accounts.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio + return accountStatusList.accountStatuses + .firstOrNull { it.accountId == accountId } as? AccountStatus.CryptoPortfolio ?: raise(IllegalStateException("No account with id $accountId")) } - private fun Set.modify( + private fun List.modify( add: List, - remove: List, + remove: List = emptyList(), ): ModifiedCurrencyList { - val mutableCurrencies = this.toMutableList() + val mutableCurrencies = this.map(CryptoCurrencyStatus::currency).toMutableList() val added = mutableListOf() val removed = mutableListOf() - val existingCurrenciesById = mutableCurrencies.associateBy(::TempID) + val existingCurrenciesById = this.associateBy(::TempID) - add.groupByNetwork { !existingCurrenciesById.containsKey(it) } + add.groupByNetwork { tempID -> + val found = existingCurrenciesById[tempID] ?: return@groupByNetwork true + + found.value is CryptoCurrencyStatus.MissedDerivation + } .forEach { (network, currenciesById) -> val coinTempId = TempID(network) @@ -164,9 +197,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) { @@ -181,23 +213,6 @@ class SaveCryptoCurrenciesUseCase( return ModifiedCurrencyList(added = added, removed = removed, total = mutableCurrencies) } - private suspend fun Raise.saveAccount(account: Account.CryptoPortfolio) { - catch( - block = { accountsCRUDRepository.saveAccount(account) }, - catch = ::raise, - ) - } - - private suspend fun Raise.derivePublicKeys( - userWalletId: UserWalletId, - currencies: List, - ) { - catch( - block = { derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) }, - catch = ::raise, - ) - } - private fun List.groupByNetwork( valuePredicate: (TempID) -> Boolean, ): LinkedHashMap> { @@ -210,13 +225,27 @@ class SaveCryptoCurrenciesUseCase( val id = TempID(currency) if (valuePredicate(id)) { - mutableMap.put(id, currency) + mutableMap[id] = currency } } return destination } + private suspend fun Raise.saveAccount(account: Account.CryptoPortfolio) { + catch( + block = { accountsCRUDRepository.saveAccount(account) }, + catch = ::raise, + ) + } + + private suspend fun derivePublicKeys( + userWalletId: UserWalletId, + currencies: List, + ): Either = Either.catch { + derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) + } + private suspend fun Raise.findToken( userWalletId: UserWalletId, networkId: String, @@ -234,52 +263,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) }, @@ -312,6 +316,12 @@ class SaveCryptoCurrenciesUseCase( derivationPath = currency.network.derivationPath, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) + + constructor(status: CryptoCurrencyStatus) : this( + networkId = status.currency.network.backendId, + derivationPath = status.currency.network.derivationPath, + contractAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress, + ) } private data class ModifiedCurrencyList( 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/usecase/ToggleTokenListGroupingUseCaseV2.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ToggleTokenListGroupingUseCaseV2.kt new file mode 100644 index 0000000000..3583eee3d2 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ToggleTokenListGroupingUseCaseV2.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.Either +import arrow.core.raise.ensure +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.core.utils.eitherOn +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.tokens.error.TokenListSortingError +import com.tangem.domain.tokens.operations.TokenListFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * Use case to toggle the grouping of token lists in account statuses. + * + * If the token list is currently ungrouped, it will be grouped by network, + * and vice versa. The use case ensures that the token list is not empty + * and not in a loading state before performing the toggle operation. + * + * @property dispatchers Provides coroutine dispatchers for executing tasks. + */ +class ToggleTokenListGroupingUseCaseV2( + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Toggles the grouping of token lists in the provided [accountStatusList]. + * + * @param accountStatusList The list of account statuses containing token lists to be toggled. + * @return Either a [TokenListSortingError] if an error occurs, or the updated [AccountStatusList] + * with toggled token list grouping. + */ + suspend operator fun invoke( + accountStatusList: AccountStatusList, + ): Either = eitherOn(dispatchers.default) { + ensure(accountStatusList.flattenCurrencies().isNotEmpty()) { + raise(TokenListSortingError.TokenListIsEmpty) + } + + ensure(accountStatusList.totalFiatBalance !is TotalFiatBalance.Loading) { + TokenListSortingError.TokenListIsLoading + } + + val updatedAccountList = accountStatusList.accountStatuses.map { account -> + if (account !is AccountStatus.CryptoPortfolio) return@map account + + account.copy(tokenList = account.tokenList.reverseGroupType()) + } + + accountStatusList.copy( + accountStatuses = updatedAccountList, + groupType = when (updatedAccountList.firstOrNull()?.getCryptoTokenList()) { + is TokenList.GroupedByNetwork -> TokensGroupType.NETWORK + is TokenList.Ungrouped -> TokensGroupType.NONE + else -> accountStatusList.groupType + }, + ) + } + + private fun TokenList.reverseGroupType(): TokenList { + return when (this) { + is TokenList.Ungrouped -> TokenListFactory.createGroupedByNetwork(this) + is TokenList.GroupedByNetwork -> TokenListFactory.createUngrouped(this) + is TokenList.Empty -> this + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ToggleTokenListSortingUseCaseV2.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ToggleTokenListSortingUseCaseV2.kt new file mode 100644 index 0000000000..ccf2416368 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ToggleTokenListSortingUseCaseV2.kt @@ -0,0 +1,71 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.Either +import arrow.core.raise.ensure +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.core.utils.eitherOn +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.tokenlist.TokenList +import com.tangem.domain.tokens.error.TokenListSortingError +import com.tangem.domain.tokens.operations.TokenListFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * Use case to toggle the sorting of token lists in account statuses to be sorted by balance. + * + * The use case ensures that the token list is not empty + * and not in a loading state before performing the sorting operation. + * + * @property dispatchers Provides coroutine dispatchers for executing tasks. + */ +class ToggleTokenListSortingUseCaseV2( + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Toggles the sorting of token lists in the provided [accountStatusList] to be sorted by balance. + * + * @param accountStatusList The list of account statuses containing token lists to be sorted. + * @return Either a [TokenListSortingError] if an error occurs, or the updated [AccountStatusList] + * with token lists sorted by balance. + */ + suspend operator fun invoke( + accountStatusList: AccountStatusList, + ): Either = eitherOn(dispatchers.default) { + ensure(accountStatusList.flattenCurrencies().isNotEmpty()) { + raise(TokenListSortingError.TokenListIsEmpty) + } + + ensure(accountStatusList.totalFiatBalance !is TotalFiatBalance.Loading) { + TokenListSortingError.TokenListIsLoading + } + + accountStatusList.copy( + accountStatuses = accountStatusList.accountStatuses.map { account -> + if (account !is AccountStatus.CryptoPortfolio) return@map account + + account.copy(tokenList = account.tokenList.sortByBalance()) + }, + sortType = TokensSortType.BALANCE, + ) + } + + private fun TokenList.sortByBalance(): TokenList { + if (this is TokenList.Empty) return this + + return TokenListFactory.create( + statuses = flattenCurrencies(), + groupType = when (this) { + is TokenList.GroupedByNetwork -> TokensGroupType.NETWORK + is TokenList.Ungrouped -> TokensGroupType.NONE + is TokenList.Empty -> { + return this + } + }, + sortType = TokensSortType.BALANCE, + ) + } +} \ No newline at end of file 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..dc33b171dd 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 @@ -1,9 +1,9 @@ package com.tangem.domain.account.status.utils 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 @@ -33,6 +33,15 @@ internal object AccountCryptoCurrencyStatusFinder { ) } + /** + * Retrieves the [AccountCryptoCurrencyStatus] for the specified [currencyId] and [network] + * from the given [accountStatusList]. + * + * @param accountStatusList the list of account statuses to search within. + * @param currencyId the ID of the cryptocurrency whose status is to be retrieved. + * @param network the network associated with the cryptocurrency. + * @return the [AccountCryptoCurrencyStatus] if found, otherwise null. + */ operator fun invoke( accountStatusList: AccountStatusList, currencyId: CryptoCurrency.ID, @@ -51,21 +60,100 @@ internal object AccountCryptoCurrencyStatusFinder { } /** - * Retrieves the expected account statuses based on the provided [network]. - * If the [network] is null, all account statuses are returned. - * If the network has a specific derivation index, it filters the accounts accordingly. + * Retrieves a map of accounts to their corresponding list of [AccountCryptoCurrencyStatus] for the specified + * list of [currencies] from the given [accountStatusList]. * - * @param network the network to filter accounts by, can be null. - * @return a set of [AccountStatus] that match the expected criteria. + * @param accountStatusList the list of account statuses to search within. + * @param currencies the list of cryptocurrencies whose statuses are to be retrieved. + * @return a map where the key is the account and the value is a list of [AccountCryptoCurrencyStatus]. */ - private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): Set { - val possibleAccountIndex = network?.getAccountIndexOrNull() + 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 [AccountCryptoCurrencyStatus] for the specified [networkId], [derivationPath], + * and optional [contractAddress] from the given [accountStatusList]. + * + * @param accountStatusList the list of account statuses to search within. + * @param networkId the ID of the network associated with the cryptocurrency. + * @param derivationPath the derivation path of the account. + * @param contractAddress the optional contract address of the token (if applicable). + * @return the [AccountCryptoCurrencyStatus] if found, otherwise null. + */ + operator fun invoke( + accountStatusList: AccountStatusList, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + contractAddress: String?, + ): AccountCryptoCurrencyStatus? { + return accountStatusList.getExpectedAccountStatuses( + rawNetworkId = networkId.rawId.value, + derivationPath = derivationPath, + ) + .asSequence() + .filterIsInstance() + .mapNotNull { accountStatus -> + val status = accountStatus.flattenCurrencies().firstOrNull { + val currency = it.currency + val isContractAddressMatch = contractAddress == null || + currency.id.contractAddress.equals(contractAddress, ignoreCase = true) + + currency.network.rawId == networkId.rawId.value && + currency.network.derivationPath.value == derivationPath.value && + isContractAddressMatch + } + ?: return@mapNotNull null + + AccountCryptoCurrencyStatus(account = accountStatus.account, status = status) + } + .firstOrNull() + } + + private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): List { + return getExpectedAccountStatuses(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath) + } + + /** + * Retrieves the expected account statuses based on the provided [rawNetworkId] and [derivationPath]. + * If either parameter is null, all account statuses are returned. + * If both parameters are provided, it filters the accounts based on the derivation index. + * + * @param rawNetworkId the raw ID of the network to filter accounts by, can be null. + * @param derivationPath the derivation path of the network to filter accounts by, can be null. + * @return a list of [AccountStatus] that match the expected criteria. + */ + private fun AccountStatusList.getExpectedAccountStatuses( + rawNetworkId: String?, + derivationPath: Network.DerivationPath?, + ): List { + val possibleAccountIndex = if (rawNetworkId != null && derivationPath != null) { + getAccountIndexOrNull(rawNetworkId, derivationPath) + } else { + null + } 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,13 +162,31 @@ 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 + return getAccountIndexOrNull(rawNetworkId = rawId, derivationPath = derivationPath) + } + + private fun getAccountIndexOrNull(rawNetworkId: String, derivationPath: Network.DerivationPath): Int? { + val blockchain = Blockchain.fromId(id = rawNetworkId) val recognizer = AccountNodeRecognizer(blockchain) return recognizer.recognize(derivationPath)?.toInt() 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..f492131ba6 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt @@ -0,0 +1,122 @@ +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 + } + + if (errors.isNotEmpty()) { + val message = "Failed to fetch next sources for $userWalletId:\n" + + errors.joinToString(separator = "\n") { "${it.first.name} – ${it.second}" } + + Timber.e(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..cd8bb66bb6 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 @@ -1,6 +1,5 @@ package com.tangem.domain.account.status.utils -import arrow.core.some import arrow.core.toOption import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -52,7 +51,7 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( .map { statusSources -> CryptoCurrencyStatusFactory.create( currency = currency, - maybeNetworkStatus = statusSources.networkStatus.some(), + maybeNetworkStatus = statusSources.networkStatus.toOption(), maybeQuoteStatus = statusSources.quoteStatus.toOption(), maybeYieldBalance = statusSources.yieldBalance.toOption(), ) @@ -62,7 +61,6 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading), ) } - .conflate() .distinctUntilChanged() } @@ -75,11 +73,15 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( val yieldBalanceFlow = if (userWallet.isMultiCurrency) { networkStatusFlow.flatMapLatest { networkStatus -> - getYieldBalanceFlow( - userWalletId = userWallet.walletId, - currencyId = currency.id, - networkStatus = networkStatus, - ) + if (networkStatus != null) { + getYieldBalanceFlow( + userWalletId = userWallet.walletId, + currencyId = currency.id, + networkStatus = networkStatus, + ) + } else { + flowOf(null) + } } } else { null @@ -88,10 +90,11 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( val quoteStatusFlow = currency.id.rawCurrencyId?.let(::getQuoteStatusFlow) return combine(networkStatusFlow, yieldBalanceFlow, quoteStatusFlow) + .distinctUntilChanged() } private fun combine( - networkStatusFlow: Flow, + networkStatusFlow: Flow, yieldBalanceFlow: Flow?, quoteStatusFlow: Flow?, ): Flow { @@ -116,19 +119,20 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( } } - private fun getNetworkStatusFlow(userWalletId: UserWalletId, network: Network): Flow { + private fun getNetworkStatusFlow(userWalletId: UserWalletId, network: Network): Flow { return singleNetworkStatusSupplier( params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = network), ) - .conflate() .distinctUntilChanged() + .onEmpty { + emit(value = null) + } } private fun getQuoteStatusFlow(rawCurrencyId: CryptoCurrency.RawID): Flow { return singleQuoteStatusSupplier( params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId), ) - .conflate() .distinctUntilChanged() } @@ -147,7 +151,6 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( singleYieldBalanceSupplier( params = SingleYieldBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId), ) - .conflate() .distinctUntilChanged() } else { flowOf(null) @@ -155,7 +158,7 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( } private data class CryptoCurrencyStatusSources( - val networkStatus: NetworkStatus, + val networkStatus: NetworkStatus? = null, val yieldBalance: YieldBalance? = null, val quoteStatus: QuoteStatus? = 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..383c4f90ec 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 @@ -1,12 +1,12 @@ 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.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow @@ -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..15c437d7aa 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 @@ -3,13 +3,11 @@ package com.tangem.domain.account.status.producer import arrow.core.nonEmptyListOf import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -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 @@ -21,8 +19,11 @@ import com.tangem.domain.models.quote.PriceChange import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.repository.NetworksRepository +import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -38,8 +39,9 @@ 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 networksRepository: NetworksRepository = mockk() private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk() private val userWalletId = UserWalletId("011") @@ -49,15 +51,21 @@ class DefaultSingleAccountStatusListProducerTest { private val producer = DefaultSingleAccountStatusListProducer( params = SingleAccountStatusListProducer.Params(userWalletId), - userWalletsListRepository = userWalletsListRepository, + accountsCRUDRepository = accountsCRUDRepository, singleAccountListSupplier = singleAccountListSupplier, + networksRepository = networksRepository, cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory, dispatchers = TestingCoroutineDispatcherProvider(), ) @AfterEach fun tearDown() { - clearMocks(singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory) + clearMocks( + accountsCRUDRepository, + singleAccountListSupplier, + networksRepository, + cryptoCurrencyStatusesFlowFactory, + ) } @Test @@ -65,9 +73,8 @@ class DefaultSingleAccountStatusListProducerTest { // Arrange val accountList = AccountList.empty(userWalletId = userWalletId) - every { - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) - } returns flowOf(accountList) + every { singleAccountListSupplier(userWalletId) } returns flowOf(accountList) + coEvery { networksRepository.hasCachedStatuses(userWalletId) } returns true // Act val actual = producer.produce().let(::getEmittedValues) @@ -75,7 +82,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -84,11 +91,19 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual).containsExactly(expected) - coVerify(ordering = Ordering.SEQUENCE) { - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + coVerifySequence { + singleAccountListSupplier(userWalletId) + networksRepository.hasCachedStatuses(userWalletId) + } + + coVerify(inverse = true) { + accountsCRUDRepository.getUserWallet(userWalletId = any()) + cryptoCurrencyStatusesFlowFactory.create(userWallet = any(), currency = any()) } } @@ -100,9 +115,8 @@ class DefaultSingleAccountStatusListProducerTest { val accountListFlow = MutableStateFlow(value = accountList) - every { - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) - } returns accountListFlow + every { singleAccountListSupplier(userWalletId) } returns accountListFlow + coEvery { networksRepository.hasCachedStatuses(userWalletId) } returns true // Act (first emission) val actual1 = producer.produce().let(::getEmittedValues) @@ -110,7 +124,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 +133,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 +145,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (second emission) val expected2 = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = updatedAccountList.mainAccount, tokenList = TokenList.Empty, @@ -138,12 +154,22 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = updatedAccountList.sortType, + groupType = updatedAccountList.groupType, ) Truth.assertThat(actual2).containsExactly(expected2) - coVerify(ordering = Ordering.SEQUENCE) { - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + coVerifySequence { + singleAccountListSupplier(userWalletId) + networksRepository.hasCachedStatuses(userWalletId) + networksRepository.hasCachedStatuses(userWalletId) + singleAccountListSupplier(userWalletId) + networksRepository.hasCachedStatuses(userWalletId) + } + + coVerify(inverse = true) { + accountsCRUDRepository.getUserWallet(userWalletId = any()) + cryptoCurrencyStatusesFlowFactory.create(userWallet = any(), currency = any()) } } @@ -153,13 +179,12 @@ class DefaultSingleAccountStatusListProducerTest { val accountList = AccountList.empty(userWalletId) val accountListFlow = MutableStateFlow(value = accountList) - every { - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) - } returns accountListFlow + every { singleAccountListSupplier(userWalletId) } returns accountListFlow + coEvery { networksRepository.hasCachedStatuses(userWalletId) } returns true val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -168,6 +193,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) // Act (first emission) @@ -183,9 +210,16 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (second emission) Truth.assertThat(actual2).containsExactly(expected) - coVerify(ordering = Ordering.SEQUENCE) { - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + coVerifySequence { + singleAccountListSupplier(userWalletId) + networksRepository.hasCachedStatuses(userWalletId) + singleAccountListSupplier(userWalletId) + networksRepository.hasCachedStatuses(userWalletId) + } + + coVerify(inverse = true) { + accountsCRUDRepository.getUserWallet(userWalletId = any()) + cryptoCurrencyStatusesFlowFactory.create(userWallet = any(), currency = any()) } } @@ -198,11 +232,10 @@ 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)) - } returns flowOf(accountList) + every { singleAccountListSupplier(userWalletId) } returns flowOf(accountList) + coEvery { networksRepository.hasCachedStatuses(userWalletId) } returns true val ethereumStatus = CryptoCurrencyStatus( currency = cryptoCurrencyFactory.ethereum, @@ -221,12 +254,14 @@ class DefaultSingleAccountStatusListProducerTest { } returns flowOf(stellarStatus) // Act - val actual = producer.produce().let(::getEmittedValues) + val flow = producer.produce() + delay(1000) + val actual = flow.let(::getEmittedValues) // Assert val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Ungrouped( @@ -239,11 +274,17 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loading, + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual).containsExactly(expected) - coVerify(ordering = Ordering.SEQUENCE) { - singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + coVerifySequence { + singleAccountListSupplier(userWalletId) + accountsCRUDRepository.getUserWallet(userWalletId = userWalletId) + cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.ethereum) + cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.stellar) + networksRepository.hasCachedStatuses(userWalletId) } } } \ No newline at end of file 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/ArchiveCryptoPortfolioUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt new file mode 100644 index 0000000000..109fe9d65d --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt @@ -0,0 +1,307 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.ArchiveCryptoPortfolioUseCase.Error +import com.tangem.domain.core.utils.lceError +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +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.account.DerivationIndex +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.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.referral.ReferralRepository +import com.tangem.domain.referral.ReferralStatus +import com.tangem.test.mock.MockAccounts.createAccountList +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 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchiveCryptoPortfolioUseCaseTest { + + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk() + private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) + private val referralRepository: ReferralRepository = mockk(relaxUnitFun = true) + + private val useCase = ArchiveCryptoPortfolioUseCase( + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + crudRepository = crudRepository, + referralRepository = referralRepository, + ) + + @BeforeEach + fun resetMocks() { + clearMocks(singleAccountStatusListSupplier, referralRepository, crudRepository) + } + + @Test + fun `invoke should archive existing crypto portfolio account`() = runTest { + // Arrange + val accountList = createAccountList(activeAccounts = 2) + val archivingAccount = accountList.accounts.last() + val updatedAccountList = (accountList - archivingAccount).getOrNull()!! + + val params = SingleAccountStatusListProducer.Params(userWalletId) + coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus() + + val referralStatus = ReferralStatus(isActive = false, token = null, address = null) + coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus + + // Act + val actual = useCase(accountId = archivingAccount.accountId) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + singleAccountStatusListSupplier.getSyncOrNull(params) + referralRepository.getReferralStatus(userWalletId.stringValue) + crudRepository.saveAccounts(updatedAccountList) + } + } + + @Test + fun `invoke should archive if referral status is active but token is null`() = runTest { + // Arrange + val accountList = createAccountList(activeAccounts = 2) + val archivingAccount = accountList.accounts.last() + val updatedAccountList = (accountList - archivingAccount).getOrNull()!! + + val params = SingleAccountStatusListProducer.Params(userWalletId) + coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus() + + val referralStatus = ReferralStatus(isActive = true, token = null, address = null) + coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus + + // Act + val actual = useCase(accountId = archivingAccount.accountId) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + singleAccountStatusListSupplier.getSyncOrNull(params) + referralRepository.getReferralStatus(userWalletId.stringValue) + crudRepository.saveAccounts(updatedAccountList) + } + } + + @Test + fun `invoke should archive if referral status is active but token is absent`() = runTest { + // Arrange + val accountList = createAccountList(activeAccounts = 2) + val archivingAccount = accountList.accounts.last() + val updatedAccountList = (accountList - archivingAccount).getOrNull()!! + + val params = SingleAccountStatusListProducer.Params(userWalletId) + coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus() + + val token = ReferralStatus.Token( + networkId = "ethereum", + contractAddress = "0x1", + ) + val referralStatus = ReferralStatus(isActive = true, token = token, address = "0xABC") + coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus + + // Act + val actual = useCase(accountId = archivingAccount.accountId) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + singleAccountStatusListSupplier.getSyncOrNull(params) + referralRepository.getReferralStatus(userWalletId.stringValue) + crudRepository.saveAccounts(updatedAccountList) + } + } + + @Test + fun `invoke returns error if account contains referral token `() = runTest { + // Arrange + val token = ReferralStatus.Token( + networkId = "ethereum", + contractAddress = "0x1", + ) + val defaultAddress = "0xABC" + + val cryptoCurrency = mockk { + every { this@mockk.network.backendId } returns token.networkId + every { this@mockk.contractAddress } returns token.contractAddress!! + } + + val networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = defaultAddress, + type = NetworkAddress.Address.Type.Primary, + ), + ) + + val cryptoCurrencyStatus = mockk { + every { this@mockk.currency } returns cryptoCurrency + every { this@mockk.value.networkAddress } returns networkAddress + } + + val accountList = createAccountList(activeAccounts = 2) + val archivingAccount = accountList.accounts.last() + val archivingAccountStatus = AccountStatus.CryptoPortfolio( + account = archivingAccount as Account.CryptoPortfolio, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Failed, + sortedBy = TokensSortType.NONE, + currencies = listOf(cryptoCurrencyStatus), + ), + priceChangeLce = Unit.lceError(), + ) + val accountListStatus = with(accountList.toStatus()) { + copy( + accountStatuses = accountStatuses.filter { it.accountId != archivingAccount.accountId } + + archivingAccountStatus, + ) + } + + (accountList - archivingAccount).getOrNull()!! + + val params = SingleAccountStatusListProducer.Params(userWalletId) + coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountListStatus + + val referralStatus = ReferralStatus(isActive = true, token = token, address = defaultAddress) + coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus + + // Act + val actual = useCase(accountId = archivingAccount.accountId) + + // Assert + val expected = Error.ActiveReferralStatus.left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + singleAccountStatusListSupplier.getSyncOrNull(params) + referralRepository.getReferralStatus(userWalletId.stringValue) + } + + coVerify(inverse = true) { crudRepository.saveAccounts(any()) } + } + + @Test + fun `invoke should return error if supplier returns null`() = runTest { + // Arrange + val accountId = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = DerivationIndex(1).getOrNull()!!, + ) + + val params = SingleAccountStatusListProducer.Params(userWalletId) + coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns null + + // Act + val actual = useCase(accountId) + + // Assert + val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { singleAccountStatusListSupplier.getSyncOrNull(params) } + coVerify(inverse = true) { + referralRepository.getReferralStatus(any()) + crudRepository.saveAccounts(any()) + } + } + + @Test + fun `invoke should return error if account not found`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId) + val accountId = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = DerivationIndex.Companion(1).getOrNull()!!, + ) + + val params = SingleAccountStatusListProducer.Params(userWalletId) + coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus() + + val referralStatus = ReferralStatus(isActive = false, token = null, address = null) + coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus + + // Act + val actual = useCase(accountId) + + // Assert + val expected = Error.CriticalTechError.AccountNotFound(accountId).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { singleAccountStatusListSupplier.getSyncOrNull(params) } + + coVerify(inverse = true) { + referralRepository.getReferralStatus(any()) + crudRepository.saveAccounts(any()) + } + } + + @Test + fun `invoke should return error if saveAccounts throws exception`() = runTest { + val accountList = createAccountList(activeAccounts = 2) + val archivingAccount = accountList.accounts.last() + val updatedAccountList = (accountList - archivingAccount).getOrNull()!! + + val params = SingleAccountStatusListProducer.Params(userWalletId) + coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus() + + val referralStatus = ReferralStatus(isActive = false, token = null, address = null) + coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus + + val exception = IllegalStateException("Save failed") + coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception + + // Act + val actual = useCase(accountId = archivingAccount.accountId) + + // Assert + val expected = Error.DataOperationFailed(exception).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + singleAccountStatusListSupplier.getSyncOrNull(params) + referralRepository.getReferralStatus(userWalletId.stringValue) + crudRepository.saveAccounts(updatedAccountList) + } + } + + private fun AccountList.toStatus(): AccountStatusList { + return AccountStatusList( + userWalletId = Companion.userWalletId, + accountStatuses = accounts.map { + AccountStatus.CryptoPortfolio( + account = it as Account.CryptoPortfolio, + tokenList = TokenList.Empty, + priceChangeLce = Unit.lceError(), + ) + }, + totalAccounts = totalAccounts, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = sortType, + groupType = groupType, + ) + } + + private companion object { + val userWalletId = UserWalletId("011") + } +} \ 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..ba62fe222b 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 @@ -1,13 +1,11 @@ package com.tangem.domain.account.status.usecase import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -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 @@ -16,8 +14,9 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import com.tangem.test.core.assertNone +import com.tangem.test.core.assertSome 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,16 +104,16 @@ 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)) + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } returns null // Act @@ -123,17 +123,17 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets - multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + accountsCRUDRepository.getUserWalletsSync() + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } } @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)) + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } returns emptySet() // Act @@ -143,8 +143,8 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets - multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + accountsCRUDRepository.getUserWalletsSync() + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } } @@ -156,9 +156,9 @@ 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)) + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } returns setOf(networkStatus) // Act @@ -168,8 +168,8 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets - multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + accountsCRUDRepository.getUserWalletsSync() + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } } @@ -183,9 +183,9 @@ 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)) + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } returns setOf(networkStatus) coEvery { singleAccountListSupplier.getSyncOrNull( @@ -200,8 +200,8 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets - multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + accountsCRUDRepository.getUserWalletsSync() + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), ) @@ -220,9 +220,9 @@ 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)) + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } returns setOf(networkStatus) coEvery { singleAccountListSupplier.getSyncOrNull( @@ -237,8 +237,8 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets - multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + accountsCRUDRepository.getUserWalletsSync() + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), ) @@ -255,9 +255,9 @@ 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)) + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) } returns setOf(networkStatus) coEvery { singleAccountListSupplier.getSyncOrNull( @@ -273,8 +273,8 @@ class GetAccountCurrencyByAddressUseCaseTest { assertSome(actual, expected) coVerifySequence { - userWalletsListRepository.userWallets - multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + accountsCRUDRepository.getUserWalletsSync() + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000) 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..3fc7c456cd 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 @@ -2,9 +2,6 @@ package com.tangem.domain.account.status.usecase import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.assertNone -import com.tangem.common.test.utils.assertSome -import com.tangem.common.test.utils.getEmittedValues import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer @@ -19,6 +16,9 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertNone +import com.tangem.test.core.assertSome +import com.tangem.test.core.getEmittedValues import io.mockk.* import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -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/usecase/ToggleTokenListGroupingUseCaseV2Test.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ToggleTokenListGroupingUseCaseV2Test.kt new file mode 100644 index 0000000000..016e88d152 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ToggleTokenListGroupingUseCaseV2Test.kt @@ -0,0 +1,213 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.utils.createGroupedByNetwork +import com.tangem.domain.account.status.utils.createStatus +import com.tangem.domain.account.status.utils.createUngrouped +import com.tangem.domain.core.utils.lceError +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListSortingError +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ToggleTokenListGroupingUseCaseV2Test { + + private val useCase = ToggleTokenListGroupingUseCaseV2(dispatchers = TestingCoroutineDispatcherProvider()) + + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + @Test + fun `when list is empty then error should be received`() = runTest { + // Arrange + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = emptyList(), + totalAccounts = 0, + totalFiatBalance = TotalFiatBalance.Failed, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = TokenListSortingError.TokenListIsEmpty.left() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when totalFiatBalance is loading then error should be received`() = runTest { + // Arrange + val tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(mockk(relaxed = true)), + ) + + val accountStatusList = createAccountStatusList(tokenList, TokensGroupType.NONE) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = TokenListSortingError.TokenListIsLoading.left() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when list is ungrouped and sorted then sorted grouped list should be received`() = runTest { + // Arrange + val tokenList = createUngrouped( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + sortedBy = TokensSortType.BALANCE, + ) + + val accountStatusList = createAccountStatusList(tokenList, TokensGroupType.NONE) + + val updatedTokenList = createGroupedByNetwork( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + sortedBy = TokensSortType.BALANCE, + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = createAccountStatusList(updatedTokenList, TokensGroupType.NETWORK).right() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when list is ungrouped and unsorted then unsorted grouped list should be received`() = runTest { + // Arrange + val tokenList = createUngrouped( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + ), + ) + + val accountStatusList = createAccountStatusList(tokenList, TokensGroupType.NONE) + + val updatedTokenList = createGroupedByNetwork( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + ), + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = createAccountStatusList(updatedTokenList, TokensGroupType.NETWORK).right() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when list is grouped and sorted then sorted ungrouped list should be received`() = runTest { + // Arrange + val tokenList = createGroupedByNetwork( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + sortedBy = TokensSortType.BALANCE, + ) + + val accountStatusList = createAccountStatusList(tokenList, TokensGroupType.NETWORK) + + val updatedTokenList = createUngrouped( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + sortedBy = TokensSortType.BALANCE, + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = createAccountStatusList(updatedTokenList, TokensGroupType.NONE).right() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when list is grouped and unsorted then unsorted ungrouped list should be received`() = runTest { + // Arrange + val tokenList = createGroupedByNetwork( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + ) + + val accountStatusList = createAccountStatusList(tokenList, TokensGroupType.NETWORK) + + val updatedTokenList = createUngrouped( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = createAccountStatusList(updatedTokenList, TokensGroupType.NONE).right() + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun createAccountStatusList(tokenList: TokenList, groupType: TokensGroupType): AccountStatusList { + val accountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = tokenList, + priceChangeLce = Unit.lceError(), + ) + + return AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalFiatBalance = tokenList.totalFiatBalance, + sortType = tokenList.sortedBy, + groupType = groupType, + ) + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ToggleTokenListSortingUseCaseV2Test.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ToggleTokenListSortingUseCaseV2Test.kt new file mode 100644 index 0000000000..d5806c8bb1 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ToggleTokenListSortingUseCaseV2Test.kt @@ -0,0 +1,153 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.utils.createGroupedByNetwork +import com.tangem.domain.account.status.utils.createStatus +import com.tangem.domain.account.status.utils.createUngrouped +import com.tangem.domain.core.utils.lceError +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListSortingError +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ToggleTokenListSortingUseCaseV2Test { + + private val useCase = ToggleTokenListSortingUseCaseV2(dispatchers = TestingCoroutineDispatcherProvider()) + + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + @Test + fun `when list is empty then error should be received`() = runTest { + // Arrange + val accountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = emptyList(), + totalAccounts = 0, + totalFiatBalance = TotalFiatBalance.Failed, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = TokenListSortingError.TokenListIsEmpty.left() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when totalFiatBalance is loading then error should be received`() = runTest { + // Arrange + val tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(mockk(relaxed = true)), + ) + + val accountStatusList = createAccountStatusList(tokenList) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = TokenListSortingError.TokenListIsLoading.left() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when list is grouped and unsorted then grouped and sorted list should be received`() = runTest { + // Arrange + val tokenList = createGroupedByNetwork( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + ), + ) + + val accountStatusList = createAccountStatusList(tokenList) + + val updatedTokenList = createGroupedByNetwork( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + sortedBy = TokensSortType.BALANCE, + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = createAccountStatusList(updatedTokenList).right() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `when list is ungrouped and unsorted then ungrouped and sorted list should be received`() = runTest { + // Arrange + val tokenList = createUngrouped( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + ), + ) + + val accountStatusList = createAccountStatusList(tokenList) + + val updatedTokenList = createUngrouped( + statuses = listOf( + createStatus(currency = cryptoCurrencyFactory.chia, fiatAmount = BigDecimal.TEN), + createStatus(currency = cryptoCurrencyFactory.ethereum, fiatAmount = BigDecimal.ONE), + createStatus(currency = cryptoCurrencyFactory.cardano, fiatAmount = BigDecimal.ZERO), + ), + sortedBy = TokensSortType.BALANCE, + ) + + // Act + val actual = useCase(accountStatusList) + + // Assert + val expected = createAccountStatusList(updatedTokenList).right() + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun createAccountStatusList(tokenList: TokenList): AccountStatusList { + val accountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = tokenList, + priceChangeLce = Unit.lceError(), + ) + + return AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(accountStatus), + totalAccounts = 1, + totalFiatBalance = tokenList.totalFiatBalance, + sortType = tokenList.sortedBy, + groupType = TokensGroupType.NONE, + ) + } +} \ No newline at end of file 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..37fd6c50b7 --- /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.test.core.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/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt index 0036579daf..a5df234f9a 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt @@ -4,7 +4,6 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.getEmittedValues import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -23,6 +22,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.single.SingleYieldBalanceProducer import com.tangem.domain.staking.single.SingleYieldBalanceSupplier +import com.tangem.test.core.getEmittedValues import io.mockk.* import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/TokenListExt.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/TokenListExt.kt new file mode 100644 index 0000000000..0745e8a650 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/TokenListExt.kt @@ -0,0 +1,60 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +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.tokenlist.TokenList +import java.math.BigDecimal + +internal fun createGroupedByNetwork( + statuses: List, + sortedBy: TokensSortType = TokensSortType.NONE, +): TokenList { + return TokenList.GroupedByNetwork( + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal("11"), source = StatusSource.ACTUAL), + sortedBy = sortedBy, + groups = statuses.map { + TokenList.GroupedByNetwork.NetworkGroup( + network = it.currency.network, + currencies = listOf(it), + ) + }, + ) +} + +internal fun createUngrouped( + statuses: List, + sortedBy: TokensSortType = TokensSortType.NONE, +): TokenList { + return TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal("11"), source = StatusSource.ACTUAL), + sortedBy = sortedBy, + currencies = statuses, + ) +} + +internal fun createStatus(currency: CryptoCurrency, fiatAmount: BigDecimal): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loaded( + amount = fiatAmount, + fiatRate = BigDecimal.ONE, + fiatAmount = fiatAmount, + priceChange = BigDecimal.ZERO, + yieldBalance = null, + hasCurrentNetworkTransactions = false, + yieldSupplyStatus = null, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "addr1q9", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) +} \ No newline at end of file diff --git a/domain/card/detekt-baseline-debug.xml b/domain/card/detekt-baseline-debug.xml new file mode 100644 index 0000000000..277a01c1fe --- /dev/null +++ b/domain/card/detekt-baseline-debug.xml @@ -0,0 +1,9 @@ + + + + + NamedArguments:TangemCardTypesResolver.kt$TangemCardTypesResolver$Token( cardToken.name, cardToken.symbol, cardToken.contractAddress, cardToken.decimals, ) + PropertyUsedBeforeDeclaration:TapWorkarounds.kt$TapWorkarounds$excludedBatches + PropertyUsedBeforeDeclaration:TapWorkarounds.kt$TapWorkarounds$excludedIssuers + + 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/CardTypesResolver.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/CardTypesResolver.kt index 625da3f339..ae9c6a0fbc 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/CardTypesResolver.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/CardTypesResolver.kt @@ -12,6 +12,7 @@ interface CardTypesResolver { fun isShibaWallet(): Boolean + // 4.12 fun isWhiteWallet(): Boolean fun isWallet2(): Boolean diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt index ee83c3d83a..db4b0973cb 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt @@ -1,7 +1,6 @@ package com.tangem.domain.card import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Blockchain.Companion.fromId import com.tangem.blockchain.common.Token import com.tangem.common.card.EllipticCurve import com.tangem.common.card.FirmwareVersion @@ -12,6 +11,7 @@ import com.tangem.domain.card.common.TapWorkarounds.isTestCard import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.card.common.getTwinCardIdForUser import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ProductType import com.tangem.operations.attestation.Attestation @@ -23,7 +23,16 @@ internal class TangemCardTypesResolver( private val walletData: WalletData?, ) : CardTypesResolver { - override fun isTangemNote(): Boolean = productType == ProductType.Note + override fun isTangemNote(): Boolean { + val isNote = productType == ProductType.Note + /** + * @workaround + * There were produced 20k Note demo cards that should work like multiwallet (except Onboarding) + * for that reasons we've just added some specific checks for their BatchId + */ + val isNotDemoNoteAsMulti = !DemoConfig.isDemoNoteAsMultiwallet(card.cardId) + return isNote && isNotDemoNoteAsMulti + } override fun isTangemWallet(): Boolean { return card.settings.isBackupAllowed && card.settings.isHDWalletAllowed && diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/common/TapWorkarounds.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/common/TapWorkarounds.kt index f3692995b3..550ddd0749 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/common/TapWorkarounds.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/common/TapWorkarounds.kt @@ -15,7 +15,6 @@ object TapWorkarounds { private const val TEST_CARD_BATCH = "99FF" private const val TEST_CARD_ID_STARTS_WITH = "FF99" private val backupRequiredFirmwareVersion = FirmwareVersion(major = 6, minor = 21) - private val demoConfig = DemoConfig() val CardDTO.isTangemTwins: Boolean get() = TwinsHelper.getTwinCardNumber(cardId) != null @@ -34,7 +33,7 @@ object TapWorkarounds { // for cards 6.21 and higher backup is not skippable val CardDTO.canSkipBackup: Boolean - get() = this.firmwareVersion < backupRequiredFirmwareVersion || demoConfig.isDemoCardId(cardId) + get() = this.firmwareVersion < backupRequiredFirmwareVersion || DemoConfig.isDemoCardId(cardId) val CardDTO.hasOldStyleDerivation: Boolean get() = batchId == "AC01" || batchId == "AC02" || batchId == "CB95" 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..af64de970d 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,11 @@ 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 + Blockchain.Plasma -> EllipticCurve.Secp256k1 + Blockchain.PlasmaTestnet -> 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..0e015b00af 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,11 @@ 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, + Blockchain.Plasma to EllipticCurve.Secp256k1, + Blockchain.PlasmaTestnet to EllipticCurve.Secp256k1, ) @Test diff --git a/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt index 7a07b10402..52df2eb43d 100644 --- a/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt +++ b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt @@ -123,6 +123,11 @@ interface UserWalletsListRepository { */ suspend fun clearPersistentData() + /** + * Checks if there are any secured wallets (wallets that are not locked with [LockMethod.NoLock]). + */ + suspend fun hasSecuredWallets(): Boolean + sealed class LockMethod { data object Biometric : LockMethod() class AccessCode(val accessCode: CharArray) : LockMethod() diff --git a/domain/common/src/main/java/com/tangem/domain/common/wallets/error/UnlockWalletError.kt b/domain/common/src/main/java/com/tangem/domain/common/wallets/error/UnlockWalletError.kt index c1f6e1fe89..a1fcb1aa6b 100644 --- a/domain/common/src/main/java/com/tangem/domain/common/wallets/error/UnlockWalletError.kt +++ b/domain/common/src/main/java/com/tangem/domain/common/wallets/error/UnlockWalletError.kt @@ -6,7 +6,20 @@ sealed interface UnlockWalletError { data object UserWalletNotFound : UnlockWalletError - data object UnableToUnlock : UnlockWalletError + sealed class UnableToUnlock : UnlockWalletError { + + data object Empty : UnableToUnlock() + + data class RawException(val throwable: Throwable) : UnableToUnlock() + + data class WithReason(val reason: Reason) : UnableToUnlock() + + sealed class Reason { + data class BiometricsAuthenticationLockout(val isPermanent: Boolean) : Reason() + data object AllKeysInvalidated : Reason() + data object BiometricsAuthenticationDisabled : Reason() + } + } data object UserCancelled : UnlockWalletError diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowSupplier.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowSupplier.kt index ab1c15002a..63fb3dae08 100644 --- a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowSupplier.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowSupplier.kt @@ -2,6 +2,7 @@ package com.tangem.domain.core.flow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull /** * [Flow] supplier @@ -16,8 +17,14 @@ interface FlowSupplier { /** Supply [Flow] by [params] */ operator fun invoke(params: Params): Flow - /** Get first [Data] by [params] or null if [Flow] is empty */ - suspend fun getSyncOrNull(params: Params): Data? { - return invoke(params).firstOrNull() + /** Synchronously get first value or null from [Flow] within [timeMillis] */ + suspend fun getSyncOrNull(params: Params, timeMillis: Long? = null): Data? { + val block = suspend { invoke(params).firstOrNull() } + + return if (timeMillis == null) { + block() + } else { + withTimeoutOrNull(timeMillis) { block() } + } } } \ No newline at end of file diff --git a/domain/demo/models/detekt-baseline-debug.xml b/domain/demo/models/detekt-baseline-debug.xml new file mode 100644 index 0000000000..638c5c1870 --- /dev/null +++ b/domain/demo/models/detekt-baseline-debug.xml @@ -0,0 +1,9 @@ + + + + + PropertyUsedBeforeDeclaration:DemoConfig.kt$DemoConfig$debugTestDemoCardIds + PropertyUsedBeforeDeclaration:DemoConfig.kt$DemoConfig$releaseDemoCardIds + PropertyUsedBeforeDeclaration:DemoConfig.kt$DemoConfig$testDemoCardIds + + diff --git a/domain/demo/models/src/main/kotlin/com/tangem/domain/demo/models/DemoConfig.kt b/domain/demo/models/src/main/kotlin/com/tangem/domain/demo/models/DemoConfig.kt index a98f1c5107..0ec194c730 100644 --- a/domain/demo/models/src/main/kotlin/com/tangem/domain/demo/models/DemoConfig.kt +++ b/domain/demo/models/src/main/kotlin/com/tangem/domain/demo/models/DemoConfig.kt @@ -5,15 +5,28 @@ import com.tangem.blockchain.common.Blockchain import java.math.BigDecimal @Suppress("LargeClass") -class DemoConfig { +object DemoConfig { - val demoBlockchains = setOf( + /** + * @workaround + * There were produced 20k Note demo cards that should work like multiwallet (except Onboarding) + * for that reasons we've just added some specific checks for their BatchId + */ + private const val DEMO_NOTE_AS_MILTIWALLET_BATCH = "DE00" + + private val demoBlockchains = setOf( Blockchain.Bitcoin, Blockchain.Ethereum, Blockchain.Dogecoin, Blockchain.Solana, ) + private val demoBlockchainsSecpOnly = setOf( + Blockchain.Bitcoin, + Blockchain.Ethereum, + Blockchain.Dogecoin, + ) + private val demoCardIds: List by lazy { val demoIds = getReleaseIds().toMutableList() if (BuildConfig.DEBUG) demoIds.addAll(debugTestDemoCardIds) @@ -28,13 +41,26 @@ class DemoConfig { Blockchain.Solana to Amount(13.246.toBigDecimal(), Blockchain.Solana), ) - fun isDemoCardId(cardId: String): Boolean = demoCardIds.contains(cardId) + fun isDemoCardId(cardId: String): Boolean = demoCardIds.contains(cardId) || + cardId.startsWith(DEMO_NOTE_AS_MILTIWALLET_BATCH) fun isTestDemoCardId(cardId: String): Boolean = testDemoCardIds.contains(cardId) fun getBalance(blockchain: Blockchain): Amount = walletBalances[blockchain]?.copy() ?: Amount(BigDecimal.ZERO, blockchain).copy() + fun isDemoNoteAsMultiwallet(cardId: String): Boolean { + return cardId.startsWith(DEMO_NOTE_AS_MILTIWALLET_BATCH) + } + + fun getDemoBlockchains(cardId: String): Set { + return if (cardId.startsWith(DEMO_NOTE_AS_MILTIWALLET_BATCH)) { + demoBlockchainsSecpOnly + } else { + demoBlockchains + } + } + private fun getReleaseIds(): List { return (releaseDemoCardIds + testDemoCardIds).distinct() } 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 4722b56cb9..fac64d171a 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/hot-wallet/.gitignore b/domain/hot-wallet/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/domain/hot-wallet/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/hot-wallet/build.gradle.kts b/domain/hot-wallet/build.gradle.kts new file mode 100644 index 0000000000..471afb3452 --- /dev/null +++ b/domain/hot-wallet/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.domain.hotwallet" +} + +dependencies { + implementation(projects.domain.core) + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + + implementation(deps.kotlin.coroutines) +} \ No newline at end of file diff --git a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/GetAccessCodeSkippedUseCase.kt b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/GetAccessCodeSkippedUseCase.kt new file mode 100644 index 0000000000..9924aa5439 --- /dev/null +++ b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/GetAccessCodeSkippedUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.hotwallet + +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged + +class GetAccessCodeSkippedUseCase( + private val hotWalletRepository: HotWalletRepository, +) { + operator fun invoke(userWalletId: UserWalletId): Flow = hotWalletRepository + .accessCodeSkipped(userWalletId) + .distinctUntilChanged() +} \ No newline at end of file diff --git a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/SetAccessCodeSkippedUseCase.kt b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/SetAccessCodeSkippedUseCase.kt new file mode 100644 index 0000000000..79b6d991e0 --- /dev/null +++ b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/SetAccessCodeSkippedUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.hotwallet + +import arrow.core.Either +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId + +class SetAccessCodeSkippedUseCase( + private val hotWalletRepository: HotWalletRepository, +) { + suspend operator fun invoke(userWalletId: UserWalletId, skipped: Boolean): Either = Either.catch { + hotWalletRepository.setAccessCodeSkipped(userWalletId, skipped) + } +} \ No newline at end of file diff --git a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/repository/HotWalletRepository.kt b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/repository/HotWalletRepository.kt new file mode 100644 index 0000000000..5bc636f2dd --- /dev/null +++ b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/repository/HotWalletRepository.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.hotwallet.repository + +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface HotWalletRepository { + + fun accessCodeSkipped(userWalletId: UserWalletId): Flow + + suspend fun setAccessCodeSkipped(userWalletId: UserWalletId, skipped: Boolean) +} \ No newline at end of file diff --git a/domain/legacy/detekt-baseline-debug.xml b/domain/legacy/detekt-baseline-debug.xml new file mode 100644 index 0000000000..3d02fabec6 --- /dev/null +++ b/domain/legacy/detekt-baseline-debug.xml @@ -0,0 +1,11 @@ + + + + + BooleanPropertyNaming:LogConfig.kt$AnalyticsHandlersLogConfig$val amplitude: Boolean = BuildConfig.LOG_ENABLED + BooleanPropertyNaming:LogConfig.kt$AnalyticsHandlersLogConfig$val appsflyer: Boolean = BuildConfig.LOG_ENABLED + BooleanPropertyNaming:LogConfig.kt$AnalyticsHandlersLogConfig$val firebase: Boolean = BuildConfig.LOG_ENABLED + BooleanPropertyNaming:LogConfig.kt$LogConfig$val storeAction: Boolean = BuildConfig.LOG_ENABLED + BooleanPropertyNaming:LogConfig.kt$NetworkLogConfig$val blockchainSdkNetwork: Boolean = BuildConfig.LOG_ENABLED + + diff --git a/domain/legacy/src/main/java/com/tangem/domain/analytics/ChangeCardAnalyticsContextUseCase.kt b/domain/legacy/src/main/java/com/tangem/domain/analytics/ChangeCardAnalyticsContextUseCase.kt deleted file mode 100644 index 3aa5e994c9..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/analytics/ChangeCardAnalyticsContextUseCase.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.domain.analytics - -import com.tangem.domain.models.scan.ScanResponse - -interface ChangeCardAnalyticsContextUseCase { - - operator fun invoke(scanResponse: ScanResponse) -} \ 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/detekt-baseline-debug.xml b/domain/manage-tokens/detekt-baseline-debug.xml new file mode 100644 index 0000000000..5c9a9d5ec2 --- /dev/null +++ b/domain/manage-tokens/detekt-baseline-debug.xml @@ -0,0 +1,9 @@ + + + + + NamedArguments:CheckIsCurrencyNotAddedUseCase.kt$CheckIsCurrencyNotAddedUseCase$isCurrencyNotAdded(userWalletId, networkId, derivationPath, contractAddress) + NamedArguments:CreateCryptoCurrencyUseCase.kt$CreateCryptoCurrencyUseCase$createCustomToken(userWalletId, networkId, derivationPath, formValues) + NamedArguments:ValidateTokenFormUseCase.kt$ValidateTokenFormUseCase$zipOrAccumulate( { ensureIsContractAddressValid(formValues.contractAddress, networkId) }, { ensureIsDecimalsValid(formValues.decimals) }, { ensure(formValues.name.isNotBlank()) { CustomTokenFormValidationException.EmptyName } }, { ensure(formValues.symbol.isNotBlank()) { CustomTokenFormValidationException.EmptySymbol } }, ) { contractAddress, decimals, _, _ -> AddCustomTokenForm.Validated.All( contractAddress = contractAddress, symbol = formValues.symbol, name = formValues.name, decimals = decimals, ) } + + 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..b82f1cf58a 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 ManageCryptoCurrenciesUseCase") 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..51fbc16bab 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 ManageCryptoCurrenciesUseCase") @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..2f6cb3035e 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 ManageCryptoCurrenciesUseCase") 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/detekt-baseline-debug.xml b/domain/markets/detekt-baseline-debug.xml new file mode 100644 index 0000000000..ef5fed0f16 --- /dev/null +++ b/domain/markets/detekt-baseline-debug.xml @@ -0,0 +1,12 @@ + + + + + BooleanPropertyNaming:FilterAvailableNetworksForWalletUseCase.kt$FilterAvailableNetworksForWalletUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:GetStakingNotificationMaxApyUseCase.kt$GetStakingNotificationMaxApyUseCase$val showStakingNotification = if (!hideClicked && walletFirstUsageDate != 0L) { currentDate - walletFirstUsageDate > TWO_WEEKS_IN_MILLIS } else { false } + MultilineLambdaItParameter:FilterAvailableNetworksForWalletUseCase.kt$FilterAvailableNetworksForWalletUseCase${ val blockchain = Blockchain.fromNetworkId(it.networkId) supportedBlockchains.contains(blockchain) } + MultilineLambdaItParameter:SaveMarketTokensUseCase.kt$SaveMarketTokensUseCase${ marketsTokenRepository.createCryptoCurrency( userWalletId = userWalletId, token = tokenMarketParams, network = it, ) } + SuspendFunWithFlowReturnType:GetStakingNotificationMaxApyUseCase.kt$GetStakingNotificationMaxApyUseCase$suspend + SuspendFunWithFlowReturnType:MarketsTokenRepository.kt$MarketsTokenRepository$suspend + + 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..77e9666e1e 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 ManageCryptoCurrenciesUseCase") @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..c8e4147a78 100644 --- a/domain/models/build.gradle.kts +++ b/domain/models/build.gradle.kts @@ -18,12 +18,11 @@ 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) - testImplementation(deps.test.junit5) + testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.truth) - testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/MobileWallet.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/MobileWallet.kt index 4ceab71fb6..2a2f33d27a 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/MobileWallet.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/MobileWallet.kt @@ -36,7 +36,9 @@ data class MobileWallet( if (!publicKey.contentEquals(other.publicKey)) return false if (chainCode != null) { if (other.chainCode == null || !chainCode.contentEquals(other.chainCode)) return false - } else if (other.chainCode != null) return false + } else if (other.chainCode != null) { + return false + } if (curve != other.curve) return false if (derivedKeys != other.derivedKeys) return false 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..b04e872b58 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 * @@ -34,4 +38,8 @@ sealed interface AccountStatus { fun flattenCurrencies(): List = when (this) { is CryptoPortfolio -> tokenList.flattenCurrencies() } + + fun getCryptoTokenList(): TokenList = when (this) { + is CryptoPortfolio -> tokenList + } } \ No newline at end of file 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/news/ArticleCategory.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ArticleCategory.kt new file mode 100644 index 0000000000..b2f818535d --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ArticleCategory.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.models.news + +import kotlinx.serialization.Serializable + +/** + * Represents an article category. + * +[REDACTED_AUTHOR] + * @param id - unique identifier of the category + * @param name - category name + */ +@Serializable +data class ArticleCategory( + val id: Int, + val name: String, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt new file mode 100644 index 0000000000..ea41bfe17f --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.models.news + +import kotlinx.serialization.Serializable + +/** + * Represents an detailed article via news. + * +[REDACTED_AUTHOR] + * @param id - unique identifier of the article + + * @param score - score of article (ex. 6.9) + * @param locale - language of the article + * @param isTrending - flag for trending article + * @param categories - list of included categories + * @param relatedTokens - tokens, which were mentioned in article + * @param title - article title + * @param newsUrl - link for article to share + * @param shortContent - short description + * @param content - main text of the article + * @param originalArticles - original articles, which were base to build detailed article. + */ +@Serializable +data class DetailedArticle( + val id: Int, + val createdAt: String, + val score: Float, + val locale: String, + val isTrending: Boolean, + val categories: List, + val relatedTokens: List, + val title: String, + val newsUrl: String, + val shortContent: String, + val content: String, + val originalArticles: List, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt new file mode 100644 index 0000000000..d087148b79 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.models.news + +import kotlinx.serialization.Serializable + +/** + * Represents an original article, which was base to build detailed article. + * +[REDACTED_AUTHOR] + * @param id - unique identifier of the article + * @param title - article title + * @param sourceName - name of original article source + * @param locale - language of the article + * @param publishedAt - date of article publishing + * @param url - link to source of original article + * @param imageUrl - image from source of original article + */ +@Serializable +data class OriginalArticle( + val id: Int, + val title: String, + val sourceName: String, + val locale: String, + val publishedAt: String, + val url: String, + val imageUrl: String?, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/RelatedToken.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/RelatedToken.kt new file mode 100644 index 0000000000..8dc34ff208 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/RelatedToken.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.models.news + +import kotlinx.serialization.Serializable + +/** + * Represents related tokens for article. + * +[REDACTED_AUTHOR] + * @param id - unique identifier of the token + * @param symbol - token symbol(BTC, ETH etc.) + * @param name - token name + */ +@Serializable +data class RelatedToken( + val id: String, + val symbol: String, + val name: String, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/ShortArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ShortArticle.kt new file mode 100644 index 0000000000..56cb8adb43 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ShortArticle.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.models.news + +import kotlinx.serialization.Serializable + +/** + * Represents an short article info in news. + * +[REDACTED_AUTHOR] + * @param id - unique identifier of the article + + * @param score - score of article (ex. 6.9) + * @param locale - language of the article + * @param isTrending - flag for trending article + * @param categories - list of included categories + * @param relatedTokens - tokens, which were mentioned in article + * @param title - article title + * @param newsUrl - link for article to share + * @param viewed - is article is viewed (store only runtime) + */ +@Serializable +data class ShortArticle( + val id: Int, + val createdAt: String, + val score: Float, + val locale: String, + val categories: List, + val relatedTokens: List, + val isTrending: Boolean, + val title: String, + val newsUrl: String, + val viewed: Boolean, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/scan/CardDTO.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/scan/CardDTO.kt index 97f3acfd0d..8757785d07 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/scan/CardDTO.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/scan/CardDTO.kt @@ -217,7 +217,9 @@ data class CardDTO( if (signature != null) { if (other.signature == null) return false if (!signature.contentEquals(other.signature)) return false - } else if (other.signature != null) return false + } else if (other.signature != null) { + return false + } return true } 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/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt index a196930c5b..30b9500769 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt @@ -76,6 +76,16 @@ fun UserWallet.requireColdWallet(): UserWallet.Cold { ?: error("This user wallet is not a cold wallet") } +@OptIn(ExperimentalContracts::class) +fun UserWallet.requireHotWallet(): UserWallet.Hot { + contract { + returns() implies (this@requireHotWallet is UserWallet.Hot) + } + + return this as? UserWallet.Hot + ?: error("This user wallet is not a hot wallet") +} + fun UserWallet.copy(name: String = this.name, walletId: UserWalletId = this.walletId): UserWallet = when (this) { is UserWallet.Cold -> this.copy(name = name, walletId = walletId) is UserWallet.Hot -> this.copy(name = name, walletId = walletId) diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountIdTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountIdTest.kt index e988ec9fc1..0bd90246fd 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountIdTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountIdTest.kt @@ -2,15 +2,15 @@ package com.tangem.domain.models.account import com.google.common.truth.Truth import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AccountIdTest { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun forCryptoPortfolio(model: ForCryptoPortfolioModel) { // Arrange val userWalletId = UserWalletId("27163F47405CE73110837F24DF82607FF11C7AF9D78C93F409E4FEAFF3400C8F") diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt index d68bc46ab9..a770886408 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/AccountNameTest.kt @@ -3,9 +3,9 @@ package com.tangem.domain.models.account import arrow.core.Either import arrow.core.left import com.google.common.truth.Truth +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource /** [REDACTED_AUTHOR] @@ -14,7 +14,7 @@ import org.junit.jupiter.params.provider.MethodSource class AccountNameTest { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun invoke(model: InvokeTestModel) { // Act val actual = AccountName(value = model.value) diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt index ec55aabdcd..5bc8836d2d 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/CryptoPortfolioIconTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth import com.tangem.domain.models.account.CryptoPortfolioIcon.Color import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.ProvideTestModels import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkObject @@ -11,7 +12,6 @@ import io.mockk.verifyOrder import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource import kotlin.random.Random /** @@ -25,7 +25,7 @@ class CryptoPortfolioIconTest { inner class OfMainAccount { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun ofMainAccount(model: OfMainAccountModel) { // Act val actual = CryptoPortfolioIcon.ofMainAccount(userWalletId = model.userWalletId) @@ -84,7 +84,7 @@ class CryptoPortfolioIconTest { inner class OfDefaultCustomAccount { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun ofCustomAccount(model: OfDefaultCustomAccountModel) { // Arrange val availableIcons = Icon.entries - setOf(Icon.Letter, Icon.Star) @@ -139,7 +139,7 @@ class CryptoPortfolioIconTest { inner class OfCustomAccountWithTypeAndColor { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun ofCustomAccount(model: OfCustomAccountModel) { // Act val actual = CryptoPortfolioIcon.ofCustomAccount(value = model.icon, color = model.color) diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/account/DerivationIndexTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/account/DerivationIndexTest.kt index f7daa8a9b2..dfb6db5ae2 100644 --- a/domain/models/src/test/kotlin/com/tangem/domain/models/account/DerivationIndexTest.kt +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/account/DerivationIndexTest.kt @@ -4,10 +4,10 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.google.common.truth.Truth +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource /** [REDACTED_AUTHOR] @@ -27,7 +27,7 @@ class DerivationIndexTest { } @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun invoke(model: InvokeTestModel) { // Act val actual = DerivationIndex(model.index) diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt b/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt index be5ea651d0..e27c6b2b47 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt @@ -22,5 +22,14 @@ interface NetworksRepository { */ suspend fun getNetworkAddresses(userWalletId: UserWalletId, network: Network): List + /** + * Returns addresses and crypto currency + * + * @param userWalletId the unique identifier of the user wallet + * @param network network id + */ suspend fun getNetworkAddresses(userWalletId: UserWalletId, network: Network.RawID): List + + /** Checks if there are cached statuses for given [userWalletId] */ + suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean } \ No newline at end of file diff --git a/domain/news/.gitignore b/domain/news/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/news/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/news/build.gradle.kts b/domain/news/build.gradle.kts new file mode 100644 index 0000000000..4af5590248 --- /dev/null +++ b/domain/news/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +dependencies { + api(projects.domain.core) + api(projects.domain.models) + api(projects.core.pagination) + implementation(deps.kotlin.serialization) +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt new file mode 100644 index 0000000000..f9f69c5b22 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.news.model + +import kotlinx.serialization.Serializable + +/** + * Represents config for request of news list. + * +[REDACTED_AUTHOR] + * + * @param language device locale (ex: en, ru). + * @param snapshot id snapshot (`meta.asOf`) to stabilize responses. + * @param tokenIds filter by tokens. + * @param categoryIds filter by category. + */ +@Serializable +data class NewsListConfig( + val language: String, + val snapshot: String, + val tokenIds: List = emptyList(), + val categoryIds: List = emptyList(), +) \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt new file mode 100644 index 0000000000..ea3de971c7 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.news.model + +import com.tangem.domain.models.news.ShortArticle +import com.tangem.pagination.BatchFlow +import com.tangem.pagination.BatchingContext + +typealias NewsListBatchingContext = BatchingContext + +typealias NewsListBatchFlow = BatchFlow, Nothing> \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt new file mode 100644 index 0000000000..c8ab6c6cd6 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -0,0 +1,67 @@ +package com.tangem.domain.news.repository + +import com.tangem.domain.news.model.NewsListBatchFlow +import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle +import kotlinx.coroutines.flow.Flow + +/** +[REDACTED_AUTHOR] + */ +interface NewsRepository { + + /** + * Returns flow of batch with pagination handling. + * + * @param context pagination context + * @param batchSize page capacity + */ + fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow + + /** + * Returns detailed article by id with locale configuration. + * @param newsId news identification + * @param language current locale + */ + suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle + + /** + * Observes cached detailed articles. + */ + fun observeDetailedArticles(): Flow> + + /** + * Fetches and caches detailed articles for provided ids in parallel. + */ + suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) + + /** + * Returns list of trending news by limit and with correct locale. + * + * @param limit + * @param language current device locale + */ + suspend fun getTrendingNews(limit: Int, language: String?): List + + /** + * Observes trending news with runtime viewed flag support. + */ + fun observeTrendingNews(): Flow> + + /** + * Refreshes trending news list and updates cache without overriding viewed status. + */ + suspend fun refreshTrendingNews(limit: Int, language: String?) + + /** + * Updates viewed flag for provided trending articles. + */ + suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) + + /** + * Returns available categories. + */ + suspend fun getCategories(): List +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt new file mode 100644 index 0000000000..35e1f8f0bb --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.news.repository.NewsRepository + +/** + * Returns the list of available news categories. + * + * Use this from presentation layer to refresh filters or render category chips. + */ +class GetNewsCategoriesUseCase( + private val repository: NewsRepository, +) { + + /** + * Fetches categories from the repository. + */ + suspend operator fun invoke(): List { + return repository.getCategories() + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsListBatchFlowUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsListBatchFlowUseCase.kt new file mode 100644 index 0000000000..f219d3cd1c --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsListBatchFlowUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.news.model.NewsListBatchFlow +import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.repository.NewsRepository + +/** + * Provides a pagination-aware flow of short articles. + * + * Mirrors behaviour of other BatchFlow use cases (e.g., manage tokens) and can be used with pagers. + */ +class GetNewsListBatchFlowUseCase( + private val repository: NewsRepository, +) { + + /** + * Creates a [NewsListBatchFlow] for the given batching context and page size. + */ + operator fun invoke(context: NewsListBatchingContext, batchSize: Int = DEFAULT_BATCH_SIZE): NewsListBatchFlow { + return repository.getNewsListBatchFlow(context, batchSize) + } + + private companion object { + private const val DEFAULT_BATCH_SIZE = 20 + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt new file mode 100644 index 0000000000..55d69e8875 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.repository.NewsRepository +import kotlinx.coroutines.flow.Flow + +/** + * Exposes trending news as a cached flow and provides helpers to refresh or mark items as viewed. + * + * Keep a single instance per process (provided via DI) and collect the flow to render trending rows. + */ +class ManageTrendingNewsUseCase(private val repository: NewsRepository) { + + /** + * Observes the current cached list of trending articles (max 10 items). + */ + operator fun invoke(): Flow> { + return repository.observeTrendingNews() + } + + /** + * Forces refresh from backend while preserving local `viewed` flags. + */ + suspend fun refresh(limit: Int, language: String?) { + repository.refreshTrendingNews(limit, language) + } + + /** + * Marks a single article as viewed/unviewed. + */ + suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { + repository.updateTrendingNewsViewed(listOf(articleId), viewed) + } + + /** + * Marks multiple articles at once (useful for bulk updates). + */ + suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { + repository.updateTrendingNewsViewed(articleIds, viewed) + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt new file mode 100644 index 0000000000..ea0dc371d9 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.news.repository.NewsRepository +import kotlinx.coroutines.flow.Flow + +/** + * Provides a hot stream with cached detailed articles and supports proactive prefetching. + * + * Subscribe once per screen to receive updates for any article that was fetched through [prefetch]. + */ +class ObserveNewsDetailsUseCase( + private val repository: NewsRepository, +) { + + /** + * Observes all cached detailed articles mapped by id. + */ + operator fun invoke(): Flow> { + return repository.observeDetailedArticles() + } + + /** + * Prefetches the given article ids (can be called with current + next ids for pager preloading). + */ + suspend fun prefetch(newsIds: Collection, language: String?) { + repository.fetchDetailedArticles(newsIds, language) + } +} \ 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/FilterNFTAvailableNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt index 2740d0e05f..f2028b738b 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt @@ -16,8 +16,8 @@ class FilterNFTAvailableNetworksUseCase( ) } - private fun List.filter(searchQuery: String) = filter { - it.name.contains(searchQuery, ignoreCase = true) || - it.currencySymbol.contains(searchQuery, ignoreCase = true) + private fun List.filter(searchQuery: String) = filter { network -> + network.name.contains(searchQuery, ignoreCase = true) || + network.currencySymbol.contains(searchQuery, ignoreCase = true) } } \ 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..e7894f68c1 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,55 @@ 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, ) { + @Deprecated("Use invokeForAccounts instead") @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/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt index 3e2efabf90..a1aaa94941 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt @@ -1,18 +1,23 @@ package com.tangem.domain.nft +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.repository.NFTRepository import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.* class ObserveAndClearNFTCacheIfNeedUseCase( private val nftRepository: NFTRepository, private val currenciesRepository: CurrenciesRepository, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val singleAccountListSupplier: SingleAccountListSupplier, ) { - operator fun invoke(userWalletId: UserWalletId): Flow> = currenciesRepository - .getWalletCurrenciesUpdates(userWalletId) + + operator fun invoke(userWalletId: UserWalletId): Flow> = getCryptoCurrencies(userWalletId) .map { it.map(CryptoCurrency::network) } .mapDiff { old, new -> // calculate networks sets difference to determine which networks were removed @@ -25,6 +30,14 @@ class ObserveAndClearNFTCacheIfNeedUseCase( } } + private fun getCryptoCurrencies(userWalletId: UserWalletId): Flow> { + return if (accountsFeatureToggles.isFeatureEnabled) { + singleAccountListSupplier(userWalletId).map(AccountList::flattenCurrencies) + } else { + currenciesRepository.getWalletCurrenciesUpdates(userWalletId) + } + } + private fun Flow.mapDiff(diff: (old: T, new: T) -> R): Flow = flow { var previous: T? = null collect { current -> diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt index 9ee3357e17..7ccdadbd7d 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt @@ -2,16 +2,15 @@ package com.tangem.domain.nft.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN -import com.tangem.core.analytics.models.AnalyticsParam.Key.STANDARD import com.tangem.core.analytics.models.AnalyticsParam.Key.COLLECTIONS import com.tangem.core.analytics.models.AnalyticsParam.Key.NFT import com.tangem.core.analytics.models.AnalyticsParam.Key.NO_COLLECTION +import com.tangem.core.analytics.models.AnalyticsParam.Key.STANDARD import com.tangem.core.analytics.models.AnalyticsParam.Key.STATE -import kotlin.collections.buildMap sealed class NFTAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent( category = "NFT", event = event, diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt index 56700b01f4..ff2085d060 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt @@ -15,7 +15,7 @@ data class OnrampOffer( ) enum class OnrampOfferAdvantages { - Default, BestRate, Fastest, + Default, BestRate, Fastest, GreatRate, } enum class OnrampOfferCategory { diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt index 17c2e41ae0..e1049344b2 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt @@ -18,31 +18,53 @@ enum class PaymentMethodType(val id: String?) { OTHER(id = null), ; + /** + * Get priority regardless of real speed. By business logic. + */ @Suppress("MagicNumber") - fun getPriority(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { + fun getPriorityForMethod(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { when (this) { GOOGLE_PAY -> 0 CARD -> 1 - SEPA -> 2 - REVOLUT_PAY -> 3 + REVOLUT_PAY -> 2 + SEPA -> 3 OTHER -> 4 } } else { when (this) { - CARD -> 0 - GOOGLE_PAY -> 1 + CARD -> 2 + REVOLUT_PAY -> 1 SEPA -> 2 - REVOLUT_PAY -> 3 + OTHER -> 3 + GOOGLE_PAY -> 4 + } + } + + @Suppress("MagicNumber") + fun getPriorityBySpeed(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { + when (this) { + GOOGLE_PAY -> 0 + REVOLUT_PAY -> 1 + CARD -> 2 + SEPA -> 3 OTHER -> 4 } + } else { + when (this) { + REVOLUT_PAY -> 0 + CARD -> 1 + SEPA -> 2 + OTHER -> 3 + GOOGLE_PAY -> 4 + } } /** * BE AWARE. HARDCODED. Returns the speed of transaction for payment method type. */ fun getProcessingSpeed(): PaymentSpeed = when (this) { - REVOLUT_PAY, GOOGLE_PAY, + REVOLUT_PAY, -> PaymentSpeed.Instant CARD -> PaymentSpeed.FewMin SEPA -> PaymentSpeed.FewDays diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt index 9a957d44ff..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..c5e6b3c1b0 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,111 @@ class GetOnrampAllOffersUseCase( val rateDif = calculateRateDif(quote.toAmount.value, bestRate) OnrampOffer(quote = quote, rateDif = rateDif, advantages = advantages) } + is OnrampQuote.AmountError -> { + OnrampOffer(quote = quote, rateDif = null, advantages = OnrampOfferAdvantages.Default) + } + is OnrampQuote.Error -> null + } + } - val groupBestRateOfferData = - methodQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) - val groupBestRateOffer = methodOffers.find { - when (val quote = it.quote) { - is OnrampQuote.Data -> quote == groupBestRateOfferData - else -> false + 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() -> { + val minAmountErrors = amountErrorQuotes.filter { it.error is OnrampError.AmountError.TooSmallError } + if (minAmountErrors.isNotEmpty()) { + minAmountErrors.minByOrNull { it.error.requiredAmount } + } else { + amountErrorQuotes.maxByOrNull { it.error.requiredAmount } } } + else -> validMethodQuotes.firstOrNull() + } + } - 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 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 { + val (availableOffers, unavailableOffers) = methodOffers.partition { it.quote is OnrampQuote.Data } + + val sortedAvailableOffers = sortAvailableOffers(availableOffers) + val sortedUnavailableOffers = sortUnavailableOffers(unavailableOffers) + + return sortedAvailableOffers + sortedUnavailableOffers + } + + private fun sortAvailableOffers(availableOffers: List): List { + return availableOffers.sortedByDescending { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } + } + + private fun sortUnavailableOffers(unavailableOffers: List): List { + if (unavailableOffers.isEmpty()) return emptyList() + + val (amountErrorOffers, otherOffers) = unavailableOffers.partition { it.quote is OnrampQuote.AmountError } + if (amountErrorOffers.isEmpty()) return otherOffers + + val (tooSmallOffers, tooBigOffers) = amountErrorOffers.partition { offer -> + val error = (offer.quote as OnrampQuote.AmountError).error + error is OnrampError.AmountError.TooSmallError + } + + val sortedTooSmallOffers = tooSmallOffers.sortedBy { offer -> + (offer.quote as OnrampQuote.AmountError).error.requiredAmount + } + val sortedTooBigOffers = tooBigOffers.sortedByDescending { offer -> + (offer.quote as OnrampQuote.AmountError).error.requiredAmount + } + + return sortedTooSmallOffers + sortedTooBigOffers + otherOffers + } + + 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..0b2aba9380 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,17 +63,22 @@ class GetOnrampOffersUseCase( recentOffer = recentOffer, bestRateOffer = bestRateOffer, fastestOffer = fastestOffer, - allOffers = offers, + allQuotes = quotes, + isSingleOffer = offers.size == 1, ) } private fun findRecentOffer(offers: List, transactions: List): OnrampOffer? { - val lastTransaction = transactions.maxByOrNull { it.timestamp } ?: return null - - return offers.find { offer -> - offer.quote.provider.id == lastTransaction.providerType && - offer.quote.paymentMethod.id == lastTransaction.paymentMethod + if (transactions.isEmpty()) return null + val matchingPairs = transactions.mapNotNull { transaction -> + if (!isRecentUsed(transaction.status)) return@mapNotNull null + val matchingOffer = offers.find { offer -> + offer.quote.provider.info.name == transaction.providerName && + offer.quote.paymentMethod.name == transaction.paymentMethod + } + matchingOffer?.let { it to transaction } } + return matchingPairs.maxByOrNull { (_, transaction) -> transaction.timestamp }?.first } private fun findBestRateOffer(offers: List, isGooglePayAvailable: Boolean): OnrampOffer? { @@ -83,14 +88,14 @@ class GetOnrampOffersUseCase( private fun findFastestOffer(offers: List, isGooglePayAvailable: Boolean): OnrampOffer? { val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() } return if (instantOffers.isNotEmpty()) { - instantOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + instantOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable)) } else { val offersBySpeed = offers.groupBy { offer -> offer.quote.paymentMethod.type.getProcessingSpeed().speed } val fastestSpeed = offersBySpeed.keys.minOrNull() ?: return null val fastestOffers = offersBySpeed[fastestSpeed] ?: return null - fastestOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + fastestOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable)) } } @@ -99,7 +104,10 @@ class GetOnrampOffersUseCase( is OnrampQuote.Data -> { when (val quote2 = offer2.quote) { is OnrampQuote.Data -> { - compareOffersByRateSpeedAndPriority(isGooglePayAvailable).compare(quote1, quote2) + compareOffersByRateSpeedAndPriority( + isGooglePayAvailable = isGooglePayAvailable, + isSepaPrioritized = true, + ).compare(quote1, quote2) } else -> 1 } @@ -108,20 +116,46 @@ 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, + allQuotes: List, + isSingleOffer: Boolean, ): List { val recommendedOffers = buildRecommendedOffers( recentOffer = recentOffer, bestRateOffer = bestRateOffer, fastestOffer = fastestOffer, + isSingleOffer = isSingleOffer, ) val shownOffersCount = (if (recentOffer != null) 1 else 0) + recommendedOffers.size - val hasMoreOffers = allOffers.size > shownOffersCount + val hasMoreOffers = allQuotes.size > shownOffersCount return buildList { if (recentOffer != null) { @@ -134,16 +168,17 @@ class GetOnrampOffersUseCase( recentOffer, bestRateOffer, fastestOffer, + isSingleOffer, ), rateDif = if (bestRateOffer != null) recentOffer.rateDif else null, ), ), - hasMoreOffers = false, + hasMoreOffers = hasMoreOffers, ), ) } - if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) { + if (recommendedOffers.isNotEmpty()) { add( OnrampOffersBlock( category = OnrampOfferCategory.Recommended, @@ -159,12 +194,16 @@ class GetOnrampOffersUseCase( recentOffer: OnrampOffer, bestRateOffer: OnrampOffer?, fastestOffer: OnrampOffer?, + isSingleOffer: Boolean, ): OnrampOfferAdvantages { + if (isSingleOffer) { + return OnrampOfferAdvantages.Default + } 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 @@ -176,13 +215,18 @@ class GetOnrampOffersUseCase( recentOffer: OnrampOffer?, bestRateOffer: OnrampOffer?, fastestOffer: OnrampOffer?, + isSingleOffer: Boolean, ): 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 = if (isSingleOffer) { + OnrampOfferAdvantages.Default + } else { + OnrampOfferAdvantages.GreatRate + }, rateDif = null, ), ) @@ -191,7 +235,11 @@ class GetOnrampOffersUseCase( if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) { add( bestRateOffer.copy( - advantages = OnrampOfferAdvantages.BestRate, + advantages = if (isSingleOffer) { + OnrampOfferAdvantages.Default + } else { + OnrampOfferAdvantages.GreatRate + }, rateDif = null, ), ) @@ -202,7 +250,11 @@ class GetOnrampOffersUseCase( ) { add( fastestOffer.copy( - advantages = OnrampOfferAdvantages.Fastest, + advantages = if (isSingleOffer) { + OnrampOfferAdvantages.Default + } else { + OnrampOfferAdvantages.Fastest + }, rateDif = if (bestRateOffer != null) fastestOffer.rateDif else null, ), ) @@ -211,15 +263,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..e613fc08b9 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,298 @@ 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 use largest required amount for TooBig errors`() = 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 = paymentMethod, + provider = provider1, + requiredAmount = BigDecimal("150.0"), + isMinAmountError = false, + ), + createMockAmountErrorQuote( + paymentMethod = paymentMethod, + provider = provider2, + requiredAmount = BigDecimal("75.0"), + isMinAmountError = false, + ), + createMockAmountErrorQuote( + paymentMethod = paymentMethod, + provider = provider3, + requiredAmount = BigDecimal("300.0"), + isMinAmountError = false, + ), + ) + + 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.Unavailable.MaxAmount(BigDecimal("300.0"))) + }, + ) + } + } + + @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 +476,7 @@ class GetOnrampAllOffersUseCaseTest { } } - private fun createMockQuote( + private fun createMockDataQuote( paymentMethod: OnrampPaymentMethod, provider: OnrampProvider, toAmount: BigDecimal, @@ -169,9 +484,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/referral/.gitignore b/domain/referral/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/referral/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/referral/build.gradle.kts b/domain/referral/build.gradle.kts new file mode 100644 index 0000000000..ed80a19c56 --- /dev/null +++ b/domain/referral/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +dependencies { + api(deps.kotlin.serialization) +} \ No newline at end of file diff --git a/domain/referral/src/main/java/com/tangem/domain/referral/ReferralRepository.kt b/domain/referral/src/main/java/com/tangem/domain/referral/ReferralRepository.kt new file mode 100644 index 0000000000..cbbf048329 --- /dev/null +++ b/domain/referral/src/main/java/com/tangem/domain/referral/ReferralRepository.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.referral + +import kotlinx.serialization.Serializable + +/** + * Repository for handling referral-related operations. + * +[REDACTED_AUTHOR] + */ +interface ReferralRepository { + + /** Retrieves the referral status for a given [userWalletId] */ + suspend fun getReferralStatus(userWalletId: String): ReferralStatus +} + +@Serializable +data class ReferralStatus( + val isActive: Boolean, + val token: Token?, + val address: String?, +) { + + @Serializable + data class Token( + val networkId: String, + val contractAddress: String?, + ) +} \ No newline at end of file diff --git a/domain/staking/build.gradle.kts b/domain/staking/build.gradle.kts index 7b65991ac1..24a4cc4ae2 100644 --- a/domain/staking/build.gradle.kts +++ b/domain/staking/build.gradle.kts @@ -37,11 +37,8 @@ dependencies { implementation(projects.libs.crypto) implementation(projects.libs.blockchainSdk) - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(tangemDeps.card.core) testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/staking/detekt-baseline-debug.xml b/domain/staking/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d84b9b39da --- /dev/null +++ b/domain/staking/detekt-baseline-debug.xml @@ -0,0 +1,15 @@ + + + + + CanBeNonNullable:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$value: Any? + MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } + MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending && action.amount < it.amount && it.type == BalanceType.STAKED && it.validatorAddress == action.validatorAddress } + NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId) + NullableToStringCall:StakingApyFlowUseCase.kt$StakingApyFlowUseCase$${yield.token.coinGeckoId} + UnnecessaryAbstractClass:MultiYieldBalanceSupplier.kt$MultiYieldBalanceSupplier$MultiYieldBalanceSupplier + UnnecessaryAbstractClass:SingleYieldBalanceSupplier.kt$SingleYieldBalanceSupplier$SingleYieldBalanceSupplier + UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf() + UseOrEmpty:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase$action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "" + + diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingAvailability.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingAvailability.kt index 189d86172e..b8afc62152 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingAvailability.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingAvailability.kt @@ -1,10 +1,8 @@ package com.tangem.domain.staking.model -import com.tangem.domain.staking.model.stakekit.Yield - sealed class StakingAvailability { - data class Available(val yield: Yield) : StakingAvailability() + data class Available(val option: StakingOption) : StakingAvailability() data object Unavailable : StakingAvailability() diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt index 004608a9f3..1a0c954937 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingEntryInfo.kt @@ -1,9 +1,5 @@ package com.tangem.domain.staking.model -import com.tangem.domain.staking.model.stakekit.Yield - data class StakingEntryInfo( - val rewardInfo: Yield.RewardInfo, - val rewardSchedule: Yield.Metadata.RewardSchedule, val tokenSymbol: String, ) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingOption.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingOption.kt new file mode 100644 index 0000000000..1a4563d0b5 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingOption.kt @@ -0,0 +1,62 @@ +package com.tangem.domain.staking.model + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import com.tangem.domain.models.staking.NetworkType +import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.domain.staking.model.stakekit.Yield + +/** + * Represents a staking option from any provider + * Unified abstraction over StakeKit and P2P staking integrations + */ +sealed interface StakingOption { + + /** Unique identifier for the staking option */ + val integrationId: String + + /** Annual Percentage Yield */ + val apy: SerializedBigDecimal + + /** Token being staked */ + val token: YieldToken // TODO p2p + + /** Whether this staking option is available */ + val isAvailable: Boolean + + /** + * StakeKit staking option + * Wraps StakeKit Yield with all validator and metadata information + */ + data class StakeKit(val yield: Yield) : StakingOption { + override val integrationId: String = yield.id + override val apy: SerializedBigDecimal = yield.apy + override val token: YieldToken = yield.token + override val isAvailable: Boolean = yield.isAvailable + } + + /** + * P2P pooled staking option + * Wraps P2P ETH Pool vault information + */ + data class P2P(val vault: P2PEthPoolVault) : StakingOption { + override val integrationId: String = + "p2p-ethereum-pooled:${vault.vaultAddress}" + override val apy: SerializedBigDecimal = vault.apy + override val token: YieldToken = createEthToken() + override val isAvailable: Boolean = !vault.isPrivate + + private fun createEthToken(): YieldToken { // TODO + return YieldToken( + name = "Ethereum", + network = NetworkType.ETHEREUM, + symbol = "ETH", + decimals = 18, + address = null, // Native token + coinGeckoId = "ethereum", + logoURI = null, + isPoints = false, + ) + } + } +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt new file mode 100644 index 0000000000..491c28a775 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt @@ -0,0 +1,44 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import org.joda.time.Instant + +/** + * P2P.org account staking information + * Contains detailed balance and exit queue information + */ +data class P2PEthPoolAccount( + val delegatorAddress: String, + val vaultAddress: String, + val stake: P2PEthPoolStake, + val availableToUnstake: SerializedBigDecimal, + val availableToWithdraw: SerializedBigDecimal, + val exitQueue: P2PEthPoolExitQueue, +) + +/** + * Current stake information + */ +data class P2PEthPoolStake( + val assets: SerializedBigDecimal, + val totalEarnedAssets: SerializedBigDecimal, +) + +/** + * Exit queue information + */ +data class P2PEthPoolExitQueue( + val total: SerializedBigDecimal, + val requests: List, +) + +/** + * Individual exit request in the queue + */ +data class P2PEthPoolExitRequest( + val ticket: String, + val totalAssets: SerializedBigDecimal, + val timestamp: Instant, + val withdrawalTimestamp: Instant, + val isClaimable: Boolean, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt new file mode 100644 index 0000000000..5e5f10593f --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt @@ -0,0 +1,100 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable +import org.joda.time.DateTime + +/** + * P2P.org staking action + * Represents a staking operation (deposit, unstake, withdraw) + */ +data class P2PEthPoolAction( + val id: String, + val type: P2PEthPoolActionType, + val status: P2PEthPoolActionStatus, + val amount: SerializedBigDecimal, + val vaultAddress: String, + val delegatorAddress: String, + val transaction: P2PEthPoolStakingTransaction?, + val createdAt: DateTime?, + val completedAt: DateTime?, + val metadata: P2PEthPoolActionMetadata?, +) + +/** + * Types of P2P staking actions + */ +@Serializable +enum class P2PEthPoolActionType { + DEPOSIT, + UNSTAKE, + WITHDRAW, + CLAIM_REWARDS, +} + +/** + * Status of P2P staking action + */ +@Serializable +enum class P2PEthPoolActionStatus { + CREATED, + WAITING_FOR_SIGNATURE, + PROCESSING, + CONFIRMED, + FAILED, + CANCELLED, +} + +/** + * Transaction details for P2P staking action + */ +data class P2PEthPoolStakingTransaction( + val id: String?, + val unsignedTransaction: P2PEthPoolUnsignedTxDetails?, + val signedTransaction: String?, + val status: P2PEthPoolTxStatus, + val gasEstimate: P2PEthPoolGasEstimate?, + val error: String?, +) + +/** + * Unsigned transaction details + */ +data class P2PEthPoolUnsignedTxDetails( + val to: String, + val data: String, + val value: SerializedBigDecimal, + val nonce: Int, + val chainId: Int, +) + +/** + * Gas estimation for transaction + */ +data class P2PEthPoolGasEstimate( + val gasLimit: SerializedBigDecimal, + val maxFeePerGas: SerializedBigDecimal, + val maxPriorityFeePerGas: SerializedBigDecimal, +) + +/** + * Transaction status + */ +@Serializable +enum class P2PEthPoolTxStatus { + UNSIGNED, + SIGNED, + PENDING, + CONFIRMED, + FAILED, +} + +/** + * Additional metadata for action + */ +data class P2PEthPoolActionMetadata( + val ticket: String?, + val exitQueuePosition: Int?, + val estimatedCompletionDate: DateTime?, + val unstakeFee: SerializedBigDecimal?, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt new file mode 100644 index 0000000000..c6ea6b9477 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt @@ -0,0 +1,55 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import org.joda.time.Instant + +/** + * P2P.org staking balance information (similar to StakeKit's YieldBalanceItem) + * Contains staked amounts, rewards, and pending actions + */ +data class P2PEthPoolStakingBalance( + val vaultAddress: String, + val delegatorAddress: String, + val items: List, + val network: P2PEthPoolNetwork, +) + +/** + * Individual balance item for P2P staking + */ +data class P2PEthPoolBalanceItem( + val type: P2PEthPoolBalanceType, + val amount: SerializedBigDecimal, + val rawAmount: String?, + val date: Instant?, + val pendingAction: P2PEthPoolPendingAction?, +) + +/** + * Types of balances in P2P staking + */ +enum class P2PEthPoolBalanceType { + STAKED, + REWARDS, + UNSTAKING, + WITHDRAWABLE, + LOCKED, +} + +/** + * Pending action information + */ +data class P2PEthPoolPendingAction( + val type: P2PEthPoolPendingActionType, + val ticket: String?, + val estimatedDate: Instant?, + val isClaimable: Boolean, +) + +/** + * Types of pending actions + */ +enum class P2PEthPoolPendingActionType { + UNSTAKE_PENDING, + WITHDRAWAL_AVAILABLE, +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBroadcastResult.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBroadcastResult.kt new file mode 100644 index 0000000000..07f04f6b10 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBroadcastResult.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable + +/** + * P2P.org transaction broadcast result + * Contains transaction confirmation details + */ +data class P2PEthPoolBroadcastResult( + val hash: String, + val status: P2PEthPoolBroadcastStatus, + val blockNumber: Int, + val transactionIndex: Int, + val gasUsed: SerializedBigDecimal, + val cumulativeGasUsed: SerializedBigDecimal, + val effectiveGasPrice: SerializedBigDecimal?, + val from: String, + val to: String, +) + +/** + * Transaction broadcast status + */ +@Serializable +enum class P2PEthPoolBroadcastStatus { + SUCCESS, // Transaction confirmed successfully + FAILED, // Transaction failed +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt new file mode 100644 index 0000000000..2321e39114 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt @@ -0,0 +1,67 @@ +package com.tangem.domain.staking.model.ethpool + +/** + * P2P.org supported networks for pooled staking + * + * @property value Network identifier used in API requests + * @property displayName Human-readable network name + * @property chainId Ethereum chain ID + */ +enum class P2PEthPoolNetwork( + val value: String, + val displayName: String, + val chainId: Int, +) { + /** + * Ethereum mainnet + * Chain ID: 1 + */ + MAINNET( + value = "mainnet", + displayName = "Ethereum", + chainId = 1, + ), + + /** + * Ethereum testnet (Holesky) + * Chain ID: 17000 + */ + TESTNET( + value = "hoodi", + displayName = "Holesky Testnet", + chainId = 17000, + ), + ; + + companion object { + /** + * Get P2PNetwork by chain ID + * + * @param chainId Ethereum chain ID + * @return P2PNetwork or null if not supported + */ + fun fromChainId(chainId: Int): P2PEthPoolNetwork? { + return entries.find { it.chainId == chainId } + } + + /** + * Get P2PNetwork by API value + * + * @param value API network identifier + * @return P2PNetwork or null if not found + */ + fun fromValue(value: String): P2PEthPoolNetwork? { + return entries.find { it.value == value } + } + + /** + * Check if chain ID is supported for P2P staking + * + * @param chainId Ethereum chain ID + * @return true if supported, false otherwise + */ + fun isSupported(chainId: Int): Boolean { + return fromChainId(chainId) != null + } + } +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt new file mode 100644 index 0000000000..70af95ac9a --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import org.joda.time.DateTime + +/** + * P2P.org rewards history entry + * Historical reward information for account + */ +data class P2PEthPoolReward( + val date: DateTime, + val apy: SerializedBigDecimal, + val balance: SerializedBigDecimal, + val rewards: SerializedBigDecimal, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt new file mode 100644 index 0000000000..b3da05663c --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt @@ -0,0 +1,77 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal + +/** + * P2P.org pooled staking information (similar to StakeKit's Yield) + * Contains vault details, APY, status, and metadata + */ +data class P2PEthPoolStaking( + val id: String, // Vault address used as unique ID + val vault: P2PEthPoolVaultDetails, + val status: Status, + val apy: SerializedBigDecimal, + val metadata: Metadata, + val isAvailable: Boolean, + val network: P2PEthPoolNetwork, +) { + + data class Status( + val enter: Boolean, // Can deposit + val exit: Boolean, // Can unstake/withdraw + ) + + data class Metadata( + val name: String, + val description: String?, + val logoUri: String?, + val documentation: String?, + val cooldownPeriod: Period?, // Exit queue waiting time + val warmupPeriod: Period?, // Time before rewards start + val minimumStake: SerializedBigDecimal?, + val maximumStake: SerializedBigDecimal?, + val fee: Fee, + val rewardSchedule: RewardSchedule, + val rewardClaiming: RewardClaiming, + ) { + + data class Period( + val days: Int, + ) + + data class Fee( + val enabled: Boolean, + val percent: SerializedBigDecimal?, + ) + + enum class RewardSchedule { + DAILY, + WEEKLY, + MONTHLY, + CONTINUOUS, + UNKNOWN, + } + + enum class RewardClaiming { + AUTO, // Automatically compounded + MANUAL, // Need to claim manually + UNKNOWN, + } + } +} + +/** + * Detailed vault information for P2P staking + */ +data class P2PEthPoolVaultDetails( + val vaultAddress: String, + val displayName: String, + val tokenSymbol: String, + val capacity: SerializedBigDecimal, + val totalAssets: SerializedBigDecimal, + val isPrivate: Boolean, + val isGenesis: Boolean, + val isSmoothingPool: Boolean, + val isErc20: Boolean, + val createdAt: String?, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt new file mode 100644 index 0000000000..5a801b205a --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal + +/** + * P2P.org unsigned transaction ready for signing + * Contains all necessary data for transaction signing + */ +data class P2PEthPoolUnsignedTx( + val serializeTx: String, + val to: String, + val data: String, + val value: SerializedBigDecimal, + val nonce: Int, + val chainId: Int, + val gasLimit: SerializedBigDecimal, + val maxFeePerGas: SerializedBigDecimal, + val maxPriorityFeePerGas: SerializedBigDecimal, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt new file mode 100644 index 0000000000..a316620bec --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable + +/** + * P2P.org pooled staking vault information + * Simplified version for vault list display + * Analogue of stakekit yield + */ +@Serializable +data class P2PEthPoolVault( + val vaultAddress: String, + val displayName: String, + val apy: SerializedBigDecimal, + val baseApy: SerializedBigDecimal, + val capacity: SerializedBigDecimal, + val totalAssets: SerializedBigDecimal, + val feePercent: SerializedBigDecimal, + val isPrivate: Boolean, + val isGenesis: Boolean, + val isSmoothingPool: Boolean, + val isErc20: Boolean, + val tokenName: String?, + val tokenSymbol: String?, + val createdAt: Long, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt index a0f770b33d..3c401fcced 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt @@ -24,6 +24,16 @@ sealed class StakingError { // endregion + // region p2p errors + + data class InvalidAmount(val message: String) : StakingError() + + data class DataError(val exception: Throwable) : StakingError() + + data class UnknownError(val exception: Throwable) : StakingError() + + // endregion + data class DomainError(val message: String?) : StakingError() } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt index 21e2e6ee0e..6c9ab7efc3 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt @@ -6,14 +6,14 @@ import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository import com.tangem.domain.models.wallet.UserWalletId /** * Use case for staking gas estimation. */ class EstimateGasUseCase( - private val stakingRepository: StakingRepository, + private val stakeKitRepository: StakeKitRepository, private val stakingErrorResolver: StakingErrorResolver, ) { @@ -23,7 +23,7 @@ class EstimateGasUseCase( params: ActionParams, ): Either { return Either.catch { - stakingRepository.estimateGas(userWalletId, network, params) + stakeKitRepository.estimateGas(userWalletId, network, params) }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchActionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchActionsUseCase.kt index f44adef4ae..bf2136e66f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchActionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchActionsUseCase.kt @@ -5,17 +5,17 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus -import com.tangem.domain.staking.repositories.StakingActionRepository +import com.tangem.domain.staking.repositories.StakeKitActionRepository import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository import com.tangem.domain.models.wallet.UserWalletId /** * Use case for getting pending actions list. */ class FetchActionsUseCase( - private val stakingRepository: StakingRepository, - private val stakingActionRepository: StakingActionRepository, + private val stakeKitRepository: StakeKitRepository, + private val stakeKitActionRepository: StakeKitActionRepository, private val stakingErrorResolver: StakingErrorResolver, ) { @@ -27,14 +27,14 @@ class FetchActionsUseCase( ): Either { return Either .catch { - val actions = stakingRepository.getActions( + val actions = stakeKitRepository.getActions( userWalletId = userWalletId, cryptoCurrency = cryptoCurrency, networkType = networkType, stakingActionStatus = stakingActionStatus, ) - stakingActionRepository.store(userWalletId, cryptoCurrency.id, actions) + stakeKitActionRepository.store(userWalletId, cryptoCurrency.id, actions) } .mapLeft { stakingErrorResolver.resolve(it) } } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt new file mode 100644 index 0000000000..3c5a7b4fe1 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.repositories.StakingErrorResolver +import com.tangem.domain.staking.repositories.StakeKitRepository +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Use case for fetching all staking options from all providers + * Fetches both StakeKit yields and P2P vaults + */ +class FetchStakingOptionsUseCase( + private val stakeKitRepository: StakeKitRepository, + private val p2pRepository: P2PEthPoolRepository, + private val stakingErrorResolver: StakingErrorResolver, +) { + suspend operator fun invoke(): Either { + return either { + catch( + block = { + coroutineScope { + launch { stakeKitRepository.fetchYields() } + launch { p2pRepository.fetchVaults() } + } + }, + catch = { stakingErrorResolver.resolve(it) }, + ) + } + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt index 168a65fc0f..86fcd054f0 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt @@ -5,19 +5,19 @@ import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository /** * Use case for getting enabled tokens */ class FetchStakingTokensUseCase( - private val stakingRepository: StakingRepository, + private val stakeKitRepository: StakeKitRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(): Either { return either { catch( - block = { stakingRepository.fetchEnabledYields() }, + block = { stakeKitRepository.fetchYields() }, catch = { stakingErrorResolver.resolve(it) }, ) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetActionRequirementAmountUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetActionRequirementAmountUseCase.kt index c3c236025c..462344cd70 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetActionRequirementAmountUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetActionRequirementAmountUseCase.kt @@ -7,7 +7,7 @@ import java.math.BigDecimal class GetActionRequirementAmountUseCase { operator fun invoke(integrationId: String, actionType: StakingActionType): BigDecimal? { - return if (StakingIntegrationID.EthereumToken.Polygon.value == integrationId && + return if (StakingIntegrationID.StakeKit.EthereumToken.Polygon.value == integrationId && actionType == StakingActionType.CLAIM_REWARDS ) { BigDecimal.ONE diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetActionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetActionsUseCase.kt index 69d66c85b3..29c649c1cf 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetActionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetActionsUseCase.kt @@ -7,7 +7,7 @@ import com.tangem.domain.core.utils.EitherFlow import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.action.StakingAction -import com.tangem.domain.staking.repositories.StakingActionRepository +import com.tangem.domain.staking.repositories.StakeKitActionRepository import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.catch @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.map * Use case for getting pending actions list. */ class GetActionsUseCase( - private val stakingActionRepository: StakingActionRepository, + private val stakeKitActionRepository: StakeKitActionRepository, private val stakingErrorResolver: StakingErrorResolver, ) { @@ -25,7 +25,7 @@ class GetActionsUseCase( userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, ): EitherFlow> { - return stakingActionRepository.get( + return stakeKitActionRepository.get( userWalletId = userWalletId, cryptoCurrencyId = cryptoCurrencyId, ).map, Either>> { it.right() } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt index 38ae3bf571..3402b6bb03 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt @@ -7,10 +7,10 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository class GetConstructedStakingTransactionUseCase( - private val stakingRepository: StakingRepository, + private val stakeKitRepository: StakeKitRepository, private val stakingErrorResolver: StakingErrorResolver, ) { @@ -20,7 +20,7 @@ class GetConstructedStakingTransactionUseCase( amount: Amount, transactionId: String, ): Either> = Either.catch { - stakingRepository.constructTransaction(networkId, fee, amount, transactionId) + stakeKitRepository.constructTransaction(networkId, fee, amount, transactionId) }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt index ecfd9fb4fb..6f7397f5a3 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingAvailabilityUseCase.kt @@ -8,8 +8,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.repositories.StakingRepository import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt index de21864225..ef77b03abb 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt @@ -3,28 +3,39 @@ package com.tangem.domain.staking import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository /** * Use case for getting entry info about staking on token screen. */ class GetStakingEntryInfoUseCase( - private val stakingRepository: StakingRepository, + private val stakeKitRepository: StakeKitRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke( cryptoCurrencyId: CryptoCurrency.ID, symbol: String, + stakingOption: StakingOption, ): Either { return Either .catch { - stakingRepository.getEntryInfo( - cryptoCurrencyId = cryptoCurrencyId, - symbol = symbol, - ) + when (stakingOption) { + is StakingOption.StakeKit -> { + stakeKitRepository.getEntryInfo( + cryptoCurrencyId = cryptoCurrencyId, + symbol = symbol, + ) + } + is StakingOption.P2P -> { + StakingEntryInfo( + tokenSymbol = "ETH", + ) + } + } } .mapLeft { stakingErrorResolver.resolve(it) } } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionsUseCase.kt index 63f92c60e5..df26a82e64 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionsUseCase.kt @@ -6,7 +6,7 @@ import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.delay @@ -14,7 +14,7 @@ import kotlinx.coroutines.delay * Use case for creating enter action */ class GetStakingTransactionsUseCase( - private val stakingRepository: StakingRepository, + private val stakeKitRepository: StakeKitRepository, private val stakingErrorResolver: StakingErrorResolver, ) { @@ -24,7 +24,7 @@ class GetStakingTransactionsUseCase( params: ActionParams, ): Either> { return Either.catch { - val createAction = stakingRepository.createAction(userWalletId, network, params) + val createAction = stakeKitRepository.createAction(userWalletId, network, params) // workaround, sometimes transaction is not created immediately after actions/enter delay(PATCH_TRANSACTION_REQUEST_DELAY) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetYieldUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetYieldUseCase.kt index b000f7126c..4dd2f1d833 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetYieldUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetYieldUseCase.kt @@ -5,25 +5,25 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository /** * Use case for getting staking yield for staking scenario start. */ class GetYieldUseCase( - private val stakingRepository: StakingRepository, + private val stakeKitRepository: StakeKitRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Either { return Either - .catch { stakingRepository.getYield(cryptoCurrencyId, symbol) } + .catch { stakeKitRepository.getYield(cryptoCurrencyId, symbol) } .mapLeft { stakingErrorResolver.resolve(it) } } suspend operator fun invoke(yieldId: String): Either { return Either - .catch { stakingRepository.getYield(yieldId) } + .catch { stakeKitRepository.getYield(yieldId) } .mapLeft { stakingErrorResolver.resolve(it) } } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/IsAnyTokenStakedUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/IsAnyTokenStakedUseCase.kt index 0346ef7f06..5beff03f92 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/IsAnyTokenStakedUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/IsAnyTokenStakedUseCase.kt @@ -3,8 +3,8 @@ package com.tangem.domain.staking import arrow.core.Either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.repositories.StakingRepository class IsAnyTokenStakedUseCase( private val stakingRepository: StakingRepository, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt index abfeab0d56..629b3e8891 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/SaveUnsubmittedHashUseCase.kt @@ -4,19 +4,19 @@ import arrow.core.Either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingTransactionHashRepository +import com.tangem.domain.staking.repositories.StakeKitTransactionHashRepository /** * Use case for saving hash that failed to submit during staking confirmation */ class SaveUnsubmittedHashUseCase( - private val stakingTransactionHashRepository: StakingTransactionHashRepository, + private val stakeKitTransactionHashRepository: StakeKitTransactionHashRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(transactionId: String, transactionHash: String): Either { return Either.catch { - stakingTransactionHashRepository.storeUnsubmittedHash( + stakeKitTransactionHashRepository.storeUnsubmittedHash( unsubmittedTransactionMetadata = UnsubmittedTransactionMetadata( transactionId = transactionId, transactionHash = transactionHash, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt index 3564651d9f..dc4b0a8832 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/SendUnsubmittedHashesUseCase.kt @@ -3,19 +3,19 @@ package com.tangem.domain.staking import arrow.core.Either import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingTransactionHashRepository +import com.tangem.domain.staking.repositories.StakeKitTransactionHashRepository /** * Use case for commiting hashes that failed to submit during staking confirmation */ class SendUnsubmittedHashesUseCase( - private val stakingTransactionHashRepository: StakingTransactionHashRepository, + private val stakeKitTransactionHashRepository: StakeKitTransactionHashRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(): Either { return Either - .catch { stakingTransactionHashRepository.sendUnsubmittedHashes() } + .catch { stakeKitTransactionHashRepository.sendUnsubmittedHashes() } .mapLeft { stakingErrorResolver.resolve(it) } } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt index fba8daa29b..1f9e111df7 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/SubmitHashUseCase.kt @@ -4,20 +4,20 @@ import arrow.core.Either import com.tangem.domain.staking.model.SubmitHashData import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.StakingErrorResolver -import com.tangem.domain.staking.repositories.StakingTransactionHashRepository +import com.tangem.domain.staking.repositories.StakeKitTransactionHashRepository /** * Use case for submitting transaction hash to stakekit */ class SubmitHashUseCase( - private val stakingTransactionHashRepository: StakingTransactionHashRepository, + private val stakeKitTransactionHashRepository: StakeKitTransactionHashRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(submitHashData: SubmitHashData): Either { return Either .catch { - stakingTransactionHashRepository.submitHash( + stakeKitTransactionHashRepository.submitHash( transactionId = submitHashData.transactionId, transactionHash = submitHashData.transactionHash, ) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt index 43e2488895..3ce629bf6f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt @@ -31,56 +31,79 @@ sealed interface StakingIntegrationID { */ val networkId: String - /** Represents blockchains whose native coins can be staked */ - enum class Coin : StakingIntegrationID { - Ton { - override val value: String = "ton-ton-chorus-one-pools-staking" - override val blockchain: Blockchain = Blockchain.TON - override val networkId: String = "ton" - }, - Solana { - override val value: String = "solana-sol-native-multivalidator-staking" - override val blockchain: Blockchain = Blockchain.Solana - override val networkId: String = "solana" - }, - Cosmos { - override val value: String = "cosmos-atom-native-staking" - override val blockchain: Blockchain = Blockchain.Cosmos - override val networkId: String = "cosmos" - }, - Tron { - override val value: String = "tron-trx-native-staking" - override val blockchain: Blockchain = Blockchain.Tron - override val networkId: String = "tron" - }, - BSC { - override val value: String = "bsc-bnb-native-staking" - override val blockchain: Blockchain = Blockchain.BSC - override val networkId: String = "binance" - }, - Cardano { - override val value: String = "cardano-ada-native-staking" - override val blockchain: Blockchain = Blockchain.Cardano - override val networkId: String = "cardano" - }, + /** + * Represents StakeKit staking integrations + */ + sealed interface StakeKit : StakingIntegrationID { + + /** Represents blockchains whose native coins can be staked via StakeKit */ + enum class Coin : StakeKit { + Ton { + override val value: String = "ton-ton-chorus-one-pools-staking" + override val blockchain: Blockchain = Blockchain.TON + override val networkId: String = "ton" + }, + Solana { + override val value: String = "solana-sol-native-multivalidator-staking" + override val blockchain: Blockchain = Blockchain.Solana + override val networkId: String = "solana" + }, + Cosmos { + override val value: String = "cosmos-atom-native-staking" + override val blockchain: Blockchain = Blockchain.Cosmos + override val networkId: String = "cosmos" + }, + Tron { + override val value: String = "tron-trx-native-staking" + override val blockchain: Blockchain = Blockchain.Tron + override val networkId: String = "tron" + }, + BSC { + override val value: String = "bsc-bnb-native-staking" + override val blockchain: Blockchain = Blockchain.BSC + override val networkId: String = "binance" + }, + Cardano { + override val value: String = "cardano-ada-native-staking" + override val blockchain: Blockchain = Blockchain.Cardano + override val networkId: String = "cardano" + }, + } + + /** + * Represents staking integrations for Ethereum-based tokens via StakeKit + * + * @property subBlockchain the specific Ethereum-based blockchain associated with the integration + */ + enum class EthereumToken(val subBlockchain: Blockchain) : StakeKit { + Polygon(subBlockchain = Blockchain.Polygon) { + override val value: String = "ethereum-matic-native-staking" + override val approval: StakingApproval.Needed = + StakingApproval.Needed(spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908") + override val networkId: String = "ethereum" + }, + ; + + /** Blockchain associated with Ethereum-based staking integrations. */ + override val blockchain: Blockchain = Blockchain.Ethereum + } + + companion object { + val entries: List by lazy { + Coin.entries + EthereumToken.entries + } + } } /** - * Represents staking integrations for Ethereum-based tokens - * - * @property subBlockchain the specific Ethereum-based blockchain associated with the integration + * Represents P2P staking integrations */ - enum class EthereumToken(val subBlockchain: Blockchain) : StakingIntegrationID { - Polygon(subBlockchain = Blockchain.Polygon) { - override val value: String = "ethereum-matic-native-staking" - override val approval: StakingApproval.Needed = - StakingApproval.Needed(spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908") + enum class P2P : StakingIntegrationID { + EthereumPooled { + override val value: String = "p2p-ethereum-pooled" + override val blockchain: Blockchain = Blockchain.Ethereum override val networkId: String = "ethereum" }, - ; - - /** Blockchain associated with Ethereum-based staking integrations. */ - override val blockchain: Blockchain = Blockchain.Ethereum } // Polkadot { @@ -111,7 +134,9 @@ sealed interface StakingIntegrationID { companion object { /** List of all native staking integration IDs */ - val entries: List by lazy { Coin.entries + EthereumToken.entries } + val entries: List by lazy { + StakeKit.Coin.entries + StakeKit.EthereumToken.entries + P2P.entries + } /** * Creates a [StakingIntegrationID] for the given cryptocurrency ID @@ -123,22 +148,14 @@ sealed interface StakingIntegrationID { fun create(currencyId: CryptoCurrency.ID): StakingIntegrationID? { val blockchain = Blockchain.fromId(id = currencyId.rawNetworkId) - val integrationId = blockchain.integrationId ?: return null - - if (integrationId is Coin && !currencyId.contractAddress.isNullOrBlank()) { - return null - } - - return when (integrationId) { - is Coin -> integrationId - is EthereumToken -> { - val coinId = integrationId.subBlockchain.toMigratedCoinId() - - if (coinId == currencyId.rawCurrencyId?.value) { - integrationId - } else { - null - } + return if (currencyId.contractAddress.isNullOrBlank()) { + // Order is not important — either P2P or Stakekit.Coin can be in any order + P2P.entries.firstOrNull { it.blockchain == blockchain } + ?: StakeKit.Coin.entries.firstOrNull { it.blockchain == blockchain } + } else { + StakeKit.EthereumToken.entries.firstOrNull { token -> + token.blockchain == blockchain && + token.subBlockchain.toMigratedCoinId() == currencyId.rawCurrencyId?.value } } } @@ -154,12 +171,4 @@ val Network.isStakingSupported: Boolean * @return `true` if staking is supported, `false` otherwise. */ val Blockchain.isStakingSupported: Boolean - get() = StakingIntegrationID.entries.any { it.blockchain == this } - -/** - * Extension property to retrieve the staking integration ID for a blockchain. - * - * @return the [StakingIntegrationID] if available, or `null` if not supported. - */ -val Blockchain.integrationId: StakingIntegrationID? - get() = StakingIntegrationID.entries.firstOrNull { it.blockchain == this } \ No newline at end of file + get() = StakingIntegrationID.entries.any { it.blockchain == this } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt new file mode 100644 index 0000000000..fabb8176d1 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -0,0 +1,134 @@ +package com.tangem.domain.staking.repositories + +import arrow.core.Either +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.ethpool.* +import com.tangem.domain.staking.model.stakekit.StakingError +import kotlinx.coroutines.flow.Flow + +interface P2PEthPoolRepository { + + /** + * Fetch and store available staking vaults + * + * @param network P2P network (MAINNET or TESTNET) + */ + suspend fun fetchVaults(network: P2PEthPoolNetwork = P2PEthPoolNetwork.MAINNET) + + /** + * Get list of available staking vaults + * + * @param network P2P network (MAINNET or TESTNET) + * @return Either error or list of vaults with APY, capacity, fees + */ + suspend fun getVaults( + network: P2PEthPoolNetwork = P2PEthPoolNetwork.MAINNET, + ): Either> + + /** + * Create unsigned transaction for depositing ETH into a vault + * + * @param network P2P network (MAINNET or TESTNET) + * @param delegatorAddress User's wallet address + * @param vaultAddress Vault contract address + * @param amount Amount of ETH to deposit + * @return Either error or unsigned transaction ready to sign + */ + suspend fun createDepositTransaction( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + amount: String, + ): Either + + /** + * Create unsigned transaction to initiate unstaking + * + * Unstaking adds funds to exit queue. After ~1-4 days, use [createWithdrawTransaction] + * to withdraw the funds. + * + * @param network P2P network (MAINNET or TESTNET) + * @param stakerPublicKey Staker's public key (note: API doc may have Bitcoin terminology) + * @param stakeTransactionHash Original stake transaction hash + * @return Either error or unsigned transaction + */ + suspend fun createUnstakeTransaction( + network: P2PEthPoolNetwork, + stakerPublicKey: String, + stakeTransactionHash: String, + ): Either + + /** + * Create unsigned transaction to withdraw funds from exit queue + * + * Only works when funds are available (after exit queue wait period). + * + * @param network P2P network (MAINNET or TESTNET) + * @param stakerAddress User's wallet address + * @return Either error or unsigned transaction with withdrawal tickets + */ + suspend fun createWithdrawTransaction( + network: P2PEthPoolNetwork, + stakerAddress: String, + ): Either + + /** + * Broadcast signed transaction to blockchain + * + * @param network P2P network (MAINNET or TESTNET) + * @param signedTransaction Signed transaction in hex format (with 0x prefix) + * @return Either error or broadcast result with transaction hash + */ + suspend fun broadcastTransaction( + network: P2PEthPoolNetwork, + signedTransaction: String, + ): Either + + /** + * Get account staking information for specific vault + * + * Returns current stake, rewards, exit queue status, and available amounts + * + * @param network P2P network (MAINNET or TESTNET) + * @param delegatorAddress User's wallet address + * @param vaultAddress Vault contract address + * @return Either error or account info + */ + suspend fun getAccountInfo( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + ): Either + + /** + * Get rewards history for account and vault + * + * @param network P2P network (MAINNET or TESTNET) + * @param delegatorAddress User's wallet address + * @param vaultAddress Vault contract address + * @param period Optional period filter in days (30, 60, or 90) + * @return Either error or list of reward entries + */ + suspend fun getRewards( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + period: Int? = null, + ): Either> + + /** + * Check P2P staking availability by finding public vault + * + * @return Flow of StakingAvailability - Available with StakingOption.P2P if public vault found, + * TemporaryUnavailable if not found or vaults empty + */ + fun getStakingAvailability(): Flow + + /** + * Check P2P staking availability synchronously + * + * @return StakingAvailability - Available with StakingOption.P2P if public vault found, + * TemporaryUnavailable if not found or vaults empty + */ + suspend fun getStakingAvailabilitySync(): StakingAvailability +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingActionRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitActionRepository.kt similarity index 92% rename from domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingActionRepository.kt rename to domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitActionRepository.kt index 3328d08add..635bbc9d1d 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingActionRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitActionRepository.kt @@ -5,7 +5,7 @@ import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow -interface StakingActionRepository { +interface StakeKitActionRepository { suspend fun store(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, actions: List) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt new file mode 100644 index 0000000000..a12b108ca4 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt @@ -0,0 +1,54 @@ +package com.tangem.domain.staking.repositories + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +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.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.models.staking.NetworkType +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus +import com.tangem.domain.staking.model.stakekit.transaction.ActionParams +import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import kotlinx.coroutines.flow.Flow + +@Suppress("TooManyFunctions") +interface StakeKitRepository { + + suspend fun fetchYields() + + fun getEnabledYields(): Flow> + + fun getStakingAvailability(rawCurrencyId: CryptoCurrency.RawID, symbol: String): Flow + + suspend fun getStakingAvailabilitySync(rawCurrencyId: CryptoCurrency.RawID, symbol: String): StakingAvailability + + suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo + + suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield + + suspend fun getYield(yieldId: String): Yield + + suspend fun getActions( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + networkType: NetworkType, + stakingActionStatus: StakingActionStatus, + ): List + + suspend fun createAction(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingAction + + suspend fun estimateGas(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingGasEstimate + + suspend fun constructTransaction( + networkId: String, + fee: Fee, + amount: Amount, + transactionId: String, + ): Pair +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingTransactionHashRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitTransactionHashRepository.kt similarity index 88% rename from domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingTransactionHashRepository.kt rename to domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitTransactionHashRepository.kt index be75380a84..97d971aabd 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingTransactionHashRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitTransactionHashRepository.kt @@ -2,7 +2,7 @@ package com.tangem.domain.staking.repositories import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata -interface StakingTransactionHashRepository { +interface StakeKitTransactionHashRepository { suspend fun submitHash(transactionId: String, transactionHash: String) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt index 7e360eb780..1cdbcd6ae3 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt @@ -1,35 +1,12 @@ package com.tangem.domain.staking.repositories -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.transaction.Fee 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.staking.model.StakingAvailability -import com.tangem.domain.staking.model.StakingEntryInfo -import com.tangem.domain.models.staking.NetworkType -import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.model.stakekit.action.StakingAction -import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus -import com.tangem.domain.staking.model.stakekit.transaction.ActionParams -import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate -import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import kotlinx.coroutines.flow.Flow -@Suppress("TooManyFunctions") interface StakingRepository { - suspend fun fetchEnabledYields() - - fun getEnabledYields(): Flow> - - suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo - - suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield - - suspend fun getYield(yieldId: String): Yield - fun getStakingAvailability(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Flow suspend fun getStakingAvailabilitySync( @@ -37,23 +14,5 @@ interface StakingRepository { cryptoCurrency: CryptoCurrency, ): StakingAvailability - suspend fun getActions( - userWalletId: UserWalletId, - cryptoCurrency: CryptoCurrency, - networkType: NetworkType, - stakingActionStatus: StakingActionStatus, - ): List - - suspend fun createAction(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingAction - - suspend fun estimateGas(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingGasEstimate - - suspend fun constructTransaction( - networkId: String, - fee: Fee, - amount: Amount, - transactionId: String, - ): Pair - suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt index 33ae4375b0..fa08534edf 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt @@ -3,4 +3,5 @@ package com.tangem.domain.staking.toggles interface StakingFeatureToggles { val isTonStakingEnabled: Boolean val isCardanoStakingEnabled: Boolean + val isEthStakingEnabled: Boolean } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt index f2ee167562..22f83164b2 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt @@ -3,7 +3,7 @@ package com.tangem.domain.staking.usecase import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.staking.repositories.StakeKitRepository import com.tangem.domain.staking.toggles.StakingFeatureToggles import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -16,12 +16,12 @@ import kotlinx.coroutines.flow.map * - value: validators */ class StakingApyFlowUseCase( - private val stakingRepository: StakingRepository, + private val stakeKitRepository: StakeKitRepository, private val stakingFeatureToggles: StakingFeatureToggles, ) { operator fun invoke(): Flow>> { - return stakingRepository.getEnabledYields() + return stakeKitRepository.getEnabledYields() .map { yields -> yields.filterNot { yield -> val isCardanoYield = yield.token.coinGeckoId == Blockchain.Cardano.toCoinId() diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt index 09f2670173..dc18df883b 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt @@ -6,12 +6,12 @@ import arrow.core.right import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.test.core.ProvideTestModels import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -47,7 +47,7 @@ internal class StakingIdFactoryTest { fun `create returns UnsupportedCurrency if integrationId is null`() = runTest { // Arrange val userWalletId = UserWalletId(stringValue = "011") - val currency = MockCryptoCurrencyFactory().ethereum + val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.Bitcoin) // Act val actual = factory.create( @@ -85,7 +85,7 @@ internal class StakingIdFactoryTest { // Assert val expected = StakingIdFactory.Error.UnableToGetAddress( - integrationId = StakingIntegrationID.Coin.Ton, + integrationId = StakingIntegrationID.StakeKit.Coin.Ton, ) Truth.assertThat(actual.leftOrNull()).isEqualTo(expected) @@ -126,31 +126,37 @@ internal class StakingIdFactoryTest { ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.TON), - expected = createStakingId(integrationId = StakingIntegrationID.Coin.Ton), + expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.Coin.Ton), ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Solana), - expected = createStakingId(integrationId = StakingIntegrationID.Coin.Solana), + expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.Coin.Solana), ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Cosmos), - expected = createStakingId(integrationId = StakingIntegrationID.Coin.Cosmos), + expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.Coin.Cosmos), ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Tron), - expected = createStakingId(integrationId = StakingIntegrationID.Coin.Tron), + expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.Coin.Tron), ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.BSC), - expected = createStakingId(integrationId = StakingIntegrationID.Coin.BSC), + expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.Coin.BSC), ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Cardano), - expected = createStakingId(integrationId = StakingIntegrationID.Coin.Cardano), + expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano), ), CreateModel( - currencyId = CryptoCurrency.ID.fromValue(value = "coin⟨ETH⟩polygon-ecosystem-token⚓"), - expected = createStakingId(integrationId = StakingIntegrationID.EthereumToken.Polygon), + currencyId = createCurrencyId(blockchain = Blockchain.Ethereum), + expected = createStakingId(integrationId = StakingIntegrationID.P2P.EthereumPooled), + ), + CreateModel( + currencyId = CryptoCurrency.ID.fromValue( + value = "token⟨ETH⟩polygon-ecosystem-token⚓0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", + ), + expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.EthereumToken.Polygon), ), ) diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt index 74095fb3da..ed60c5fd0c 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt @@ -2,10 +2,10 @@ package com.tangem.domain.staking import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain -import com.tangem.common.test.utils.ProvideTestModels import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -28,23 +28,35 @@ class StakingIntegrationIDTest { } @Test - fun `all blockchains are unique`() { + fun `all Coin blockchains are unique`() { // Act - val actual = StakingIntegrationID.entries.distinctBy(StakingIntegrationID::blockchain) + val actual = StakingIntegrationID.StakeKit.Coin.entries + .distinctBy(StakingIntegrationID.StakeKit.Coin::blockchain) // Assert - val expected = StakingIntegrationID.entries.size + val expected = StakingIntegrationID.StakeKit.Coin.entries.size + Truth.assertThat(actual).hasSize(expected) + } + + @Test + fun `all P2P blockchains are unique`() { + // Act + val actual = StakingIntegrationID.P2P.entries + .distinctBy(StakingIntegrationID.P2P::blockchain) + + // Assert + val expected = StakingIntegrationID.P2P.entries.size Truth.assertThat(actual).hasSize(expected) } @Test fun `all sub blockchains are unique`() { // Act - val actual = StakingIntegrationID.EthereumToken.entries - .distinctBy(StakingIntegrationID.EthereumToken::subBlockchain) + val actual = StakingIntegrationID.StakeKit.EthereumToken.entries + .distinctBy(StakingIntegrationID.StakeKit.EthereumToken::subBlockchain) // Assert - val expected = StakingIntegrationID.EthereumToken.entries.size + val expected = StakingIntegrationID.StakeKit.EthereumToken.entries.size Truth.assertThat(actual).hasSize(expected) } @@ -65,8 +77,8 @@ class StakingIntegrationIDTest { @Test fun `all EthereumToken IDs are ethereum based blockchains`() { // Act - val actual = StakingIntegrationID.EthereumToken.entries - .map(StakingIntegrationID.EthereumToken::subBlockchain) + val actual = StakingIntegrationID.StakeKit.EthereumToken.entries + .map(StakingIntegrationID.StakeKit.EthereumToken::subBlockchain) .all { it.isEvm() } // Assert @@ -76,8 +88,8 @@ class StakingIntegrationIDTest { @Test fun `all Coin IDs do not need staking approval`() { // Act - val actual = StakingIntegrationID.Coin.entries - .map(StakingIntegrationID.Coin::approval) + val actual = StakingIntegrationID.StakeKit.Coin.entries + .map(StakingIntegrationID.StakeKit.Coin::approval) .none { it is StakingApproval.Needed } // Assert @@ -87,8 +99,8 @@ class StakingIntegrationIDTest { @Test fun `all EthereumToken IDs need staking approval`() { // Act - val actual = StakingIntegrationID.EthereumToken.entries - .map(StakingIntegrationID.EthereumToken::approval) + val actual = StakingIntegrationID.StakeKit.EthereumToken.entries + .map(StakingIntegrationID.StakeKit.EthereumToken::approval) .all { it is StakingApproval.Needed } // Assert @@ -116,31 +128,35 @@ class StakingIntegrationIDTest { ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.TON), - expected = StakingIntegrationID.Coin.Ton, + expected = StakingIntegrationID.StakeKit.Coin.Ton, ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Solana), - expected = StakingIntegrationID.Coin.Solana, + expected = StakingIntegrationID.StakeKit.Coin.Solana, ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Cosmos), - expected = StakingIntegrationID.Coin.Cosmos, + expected = StakingIntegrationID.StakeKit.Coin.Cosmos, ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Tron), - expected = StakingIntegrationID.Coin.Tron, + expected = StakingIntegrationID.StakeKit.Coin.Tron, ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.BSC), - expected = StakingIntegrationID.Coin.BSC, + expected = StakingIntegrationID.StakeKit.Coin.BSC, ), CreateModel( currencyId = createCurrencyId(blockchain = Blockchain.Cardano), - expected = StakingIntegrationID.Coin.Cardano, + expected = StakingIntegrationID.StakeKit.Coin.Cardano, + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Ethereum), + expected = StakingIntegrationID.P2P.EthereumPooled, ), CreateModel( currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩polygon-ecosystem-token⚓1234567890"), - expected = StakingIntegrationID.EthereumToken.Polygon, + expected = StakingIntegrationID.StakeKit.EthereumToken.Polygon, ), CreateModel( currencyId = CryptoCurrency.ID.fromValue(value = "token⟨SOLANA⟩solana⚓1234567890"), 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/GetSwapPairsUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairsUseCase.kt index c45209f0ad..09bd863c48 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairsUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairsUseCase.kt @@ -63,14 +63,18 @@ class GetSwapPairsUseCase( groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus, cryptoCurrencyStatusList: List, ): SwapCurrenciesGroup { - val availableCryptoCurrencies = filter { pair -> - filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId - }.filter { pair -> - // Search available to swap currency - cryptoCurrencyStatusList.any { currencyStatus -> - currencyStatus.currency.id == pair.to.currency.id + val availableCryptoCurrencies = asSequence() + .filter { pair -> + filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId } - }.map { pair -> SwapCryptoCurrency(groupingCurrency(pair), pair.providers) } + .filter { pair -> + // Search available to swap currency + cryptoCurrencyStatusList.any { currencyStatus -> + currencyStatus.currency.id == pair.to.currency.id + } + } + .map { pair -> SwapCryptoCurrency(groupingCurrency(pair), pair.providers) } + .toList() val unavailableCryptoCurrencies = cryptoCurrencyStatusList - availableCryptoCurrencies.map { it.currencyStatus }.toSet() diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt index f2d6c9a4c4..7cd009b31b 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapSupportedPairsUseCase.kt @@ -59,16 +59,20 @@ class GetSwapSupportedPairsUseCase( groupingCurrency: (SwapPairModel) -> CryptoCurrencyStatus, cryptoCurrencyList: List, ): SwapCurrenciesGroup { - val availableCryptoCurrencies = filter { pair -> - filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId - }.filter { pair -> - // Search available to swap currency - cryptoCurrencyList.any { currencyStatus -> - // Allowed only on networks without tx extras (e.i. memo and destination tag) - val isExtrasSupported = currencyStatus.network.transactionExtrasType.isTxExtrasSupported() - currencyStatus.id == pair.to.currency.id && !isExtrasSupported + val availableCryptoCurrencies = asSequence() + .filter { pair -> + filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId } - }.map { pair -> SwapCryptoCurrency(groupingCurrency(pair), pair.providers) } + .filter { pair -> + // Search available to swap currency + cryptoCurrencyList.any { currencyStatus -> + // Allowed only on networks without tx extras (e.i. memo and destination tag) + val isExtrasSupported = currencyStatus.network.transactionExtrasType.isTxExtrasSupported() + currencyStatus.id == pair.to.currency.id && !isExtrasSupported + } + } + .map { pair -> SwapCryptoCurrency(groupingCurrency(pair), pair.providers) } + .toList() val unavailableCryptoCurrencies = cryptoCurrencyList - availableCryptoCurrencies.map { it.currencyStatus.currency }.toSet() 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/build.gradle.kts b/domain/tokens/build.gradle.kts index a1d0b5b08f..68bfb40179 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -59,10 +59,7 @@ dependencies { } /** Tests */ - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/tokens/detekt-baseline-debug.xml b/domain/tokens/detekt-baseline-debug.xml new file mode 100644 index 0000000000..f1b234f537 --- /dev/null +++ b/domain/tokens/detekt-baseline-debug.xml @@ -0,0 +1,34 @@ + + + + + BooleanPropertyNaming:TokenActionsState.kt$TokenActionsState.ActionState.Swap$val showBadge: Boolean + CanBeNonNullable:BaseActionsFactory.kt$BaseActionsFactory$requirementsDeferred: Deferred<AssetRequirementsCondition?>? + CanBeNonNullable:CommonActionsFactory.kt$CommonActionsFactory$swapUnavailableReasonDeferred: Deferred<ScenarioUnavailabilityReason>? + ExplicitCollectionElementAccessMethod:GetWalletTotalBalanceUseCase.kt$GetWalletTotalBalanceUseCase$walletBalanceCache.put(userWalletId, content) + MultilineLambdaItParameter:AddCryptoCurrenciesUseCase.kt$AddCryptoCurrenciesUseCase${ it.network.backendId == networkId && !it.isCustom && it.contractAddress.equals(contractAddress, true) } + MultilineLambdaItParameter:AddCryptoCurrenciesUseCase.kt$AddCryptoCurrenciesUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(IllegalStateException("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } + MultilineLambdaItParameter:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations${ singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(rawCurrencyId = it)) .firstOrNull() } + MultilineLambdaItParameter:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations${ val exception = IllegalStateException("$it") Error.DataError(exception) } + MultilineLambdaItParameter:FetchCurrencyStatusUseCase.kt$FetchCurrencyStatusUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(IllegalStateException("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } + MultilineLambdaItParameter:GetBalanceNotEnoughForFeeWarningUseCase.kt$GetBalanceNotEnoughForFeeWarningUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } + MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ TokenActionsState( walletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, states = it.toList(), ) } + MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ outdatedDataActionsFactory.create( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, stakingAvailability = it, ) } + MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ if (isNeedToCreateAccountWithoutReserve(networkId = currencyStatus.currency.network.rawId)) { CryptoCurrencyWarning.TopUpWithoutReserve } else { CryptoCurrencyWarning.SomeNetworksNoAccount( amountToCreateAccount = it.amountToCreateAccount, amountCurrency = currencyStatus.currency, ) } } + MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } + MultilineLambdaItParameter:GetWalletTotalBalanceUseCase.kt$GetWalletTotalBalanceUseCase${ Timber.e("failed to load balances with error: $it") TotalFiatBalance.Failed } + MultilineLambdaItParameter:PriceChangeCalculator.kt$PriceChangeCalculator${ val weight = it.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) val priceChange = it.value.priceChange.orZero() weight * priceChange } + MultilineLambdaItParameter:WalletBalanceFetcher.kt$WalletBalanceFetcher${ val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${it.id}") } stakingId } + NamedArguments:ApplyTokenListSortingUseCase.kt$ApplyTokenListSortingUseCase$saveTokens(userWalletId, currencies, isGrouped, isSortedByBalance) + NamedArguments:CryptoCurrencyStatusFactory.kt$CryptoCurrencyStatusFactory$createStatus(currency, status, quoteStatus, maybeYieldBalance) + NamedArguments:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase$combine( getCoinRelatedWarnings( userWalletId = userWalletId, networkId = currency.network.id, currencyId = currency.id, derivationPath = derivationPath, isSingleWalletWithTokens = isSingleWalletWithTokens, ), flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)), flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)), flowOf(currencyChecksRepository.getFeeResourceAmount(userWalletId, currency.network)), ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource -> setOfNotNull( maybeRentWarning, maybeEdWarning?.let { getExistentialDepositWarning(currency, it) }, maybeFeeResource?.let { getFeeResourceWarning(it) }, * coinRelatedWarnings.toTypedArray(), getNetworkUnavailableWarning(currencyStatus), getNetworkNoAccountWarning(currencyStatus), getBeaconChainShutdownWarning(rawId = currency.network.id.rawId), getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency), getMigrationFromMaticToPolWarning(currency), ) } + NoNameShadowing:WalletBalanceFetcher.kt$WalletBalanceFetcher${ it is StakingIdFactory.Error.UnableToGetAddress } + NullableBooleanCheck:GetCurrencyCheckUseCase.kt$GetCurrencyCheckUseCase$recipientAddress?.let { currencyChecksRepository.checkIfAccountFunded( userWalletId, network, recipientAddress, ) } ?: false + SuspendFunWithFlowReturnType:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations$suspend + SuspendFunWithFlowReturnType:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase$suspend + SuspendFunWithFlowReturnType:GetNetworkCoinStatusUseCase.kt$GetNetworkCoinStatusUseCase$suspend + SuspendFunWithFlowReturnType:GetSingleCryptoCurrencyStatusUseCase.kt$GetSingleCryptoCurrencyStatusUseCase$suspend + UnnecessaryAbstractClass:MultiWalletCryptoCurrenciesSupplier.kt$MultiWalletCryptoCurrenciesSupplier$MultiWalletCryptoCurrenciesSupplier + UnsafeCallOnNullableType:CommonActionsFactory.kt$CommonActionsFactory$swapUnavailableReasonDeferred!! + + 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..f6896dbad8 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 ManageCryptoCurrenciesUseCase") @Suppress("LongParameterList") class AddCryptoCurrenciesUseCase( private val currenciesRepository: CurrenciesRepository, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt index 1bad208906..e98428eee2 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/ApplyTokenListSortingUseCase.kt @@ -15,6 +15,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext +@Deprecated("Use ApplyAccountListSortingUseCase") class ApplyTokenListSortingUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, 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/GetCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt index 457f177ea4..839334727f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt @@ -2,10 +2,11 @@ package com.tangem.domain.tokens import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId +@Deprecated("Use MultiWalletCryptoCurrenciesSupplier") class GetCryptoCurrenciesUseCase( private val currenciesRepository: CurrenciesRepository, ) { diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt index 661f363200..1ff28dd2af 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt @@ -9,14 +9,14 @@ import com.tangem.domain.tokens.model.remove.RemoveCurrencyError import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade -@Deprecated("Use SaveCryptoCurrenciesUseCase") +@Deprecated("Use ManageCryptoCurrenciesUseCase") class RemoveCurrencyUseCase( private val currenciesRepository: CurrenciesRepository, private val walletManagersFacade: WalletManagersFacade, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ) { - @Deprecated("Use SaveCryptoCurrenciesUseCase") + @Deprecated("Use ManageCryptoCurrenciesUseCase") suspend operator fun invoke( userWalletId: UserWalletId, currency: CryptoCurrency, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index 2809346d58..d18e2e36f4 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -172,12 +172,12 @@ internal open class BaseActionsFactory( return if (stakingAvailability is StakingAvailability.Available) { ActionState.Stake( unavailabilityReason = ScenarioUnavailabilityReason.None, - yield = stakingAvailability.yield, + option = stakingAvailability.option, ) } else { ActionState.Stake( unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name), - yield = null, + option = null, ) } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt index 2e0d1cbf54..f6ff91d2fd 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt @@ -96,7 +96,7 @@ internal class OutdatedDataActionsFactory( } else { val stakingAction = ActionState.Stake( unavailabilityReason = ScenarioUnavailabilityReason.UsedOutdatedData, - yield = null, + option = null, ) stakingAction.disabled() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt index e2c0e5ff7a..4850068923 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt @@ -65,7 +65,7 @@ internal class UnreachableActionsFactory( showBadge = false, ), ActionState.Sell(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable), - ActionState.Stake(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable, yield = null), + ActionState.Stake(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable, option = null), ).disabled() // endregion 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/model/TokenActionsState.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt index 306a8cba00..1260157432 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/model/TokenActionsState.kt @@ -2,7 +2,7 @@ package com.tangem.domain.tokens.model import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingOption data class TokenActionsState( val walletId: UserWalletId, @@ -24,7 +24,7 @@ data class TokenActionsState( data class Stake( override val unavailabilityReason: ScenarioUnavailabilityReason, - val yield: Yield?, + val option: StakingOption?, ) : ActionState() data class Swap( 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..91a762bc11 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 @@ -27,6 +27,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ + @Deprecated("Use ManageCryptoCurrenciesUseCase") suspend fun saveTokens( userWalletId: UserWalletId, currencies: List, @@ -34,14 +35,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 +43,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ - @Deprecated("Tech debt") + @Deprecated("Use ManageCryptoCurrenciesUseCase") suspend fun addCurrenciesCache(userWalletId: UserWalletId, currencies: List): List /** @@ -61,6 +54,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If multi-currency user wallet * ID provided. */ + @Deprecated("Use ManageCryptoCurrenciesUseCase") suspend fun removeCurrency(userWalletId: UserWalletId, currency: CryptoCurrency) /** @@ -71,6 +65,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ + @Deprecated("Use ManageCryptoCurrenciesUseCase") suspend fun removeCurrencies(userWalletId: UserWalletId, currencies: List) /** @@ -82,6 +77,7 @@ interface CurrenciesRepository { * @param userWalletId The unique identifier of the user wallet. * @return A list of [CryptoCurrency]. */ + @Deprecated("Use MultiWalletCryptoCurrenciesSupplier") fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> /** @@ -127,18 +123,6 @@ interface CurrenciesRepository { id: CryptoCurrency.ID, ): CryptoCurrency - /** - * Retrieves updates of the list of cryptocurrencies within a multi-currency wallet. - * - * Loads remote cryptocurrencies if they have expired. - * - * @param userWalletId The unique identifier of the user wallet. - * @return A [Flow] emitting the set of cryptocurrencies associated with the user wallet. - * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet - * ID provided. - */ - fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> - /** * Retrieves the list of cryptocurrencies within a multi-currency wallet. * @@ -150,44 +134,12 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ + @Deprecated("Use MultiWalletCryptoCurrenciesSupplier") suspend fun getMultiCurrencyWalletCurrenciesSync( userWalletId: UserWalletId, 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. * @@ -209,6 +161,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ + @Deprecated("Use SingleAccountListSupplier instead") fun isTokensGrouped(userWalletId: UserWalletId): Flow /** @@ -219,6 +172,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ + @Deprecated("Use SingleAccountListSupplier instead") fun isTokensSortedByBalance(userWalletId: UserWalletId): Flow /** @@ -252,6 +206,7 @@ interface CurrenciesRepository { ): CryptoCurrency.Token /** Get crypto currencies by [currencyRawId] from all user wallets */ + @Deprecated("Use MultiAccountListSupplier instead") fun getAllWalletsCryptoCurrencies(currencyRawId: CryptoCurrency.RawID): Flow>> fun isNetworkFeeZero(userWalletId: UserWalletId, network: Network): Boolean @@ -264,6 +219,7 @@ interface CurrenciesRepository { * @param userWalletId The unique identifier of the user wallet to sync tokens for. * @throws Exception if the sync request to the backend fails */ + @Deprecated("Use AccountsCRUDRepository instead") @Throws suspend fun syncTokens(userWalletId: UserWalletId) 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/operations/CryptoCurrencyStatusFactoryTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt index 5888b68d94..22c3bb6eff 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt @@ -422,7 +422,7 @@ class CryptoCurrencyStatusFactoryTest { // Arrange val yieldBalance = YieldBalance.Data( stakingId = StakingID( - integrationId = StakingIntegrationID.Coin.Cardano.value, + integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = networkAddress.defaultAddress.value, ), source = StatusSource.ACTUAL, @@ -435,7 +435,7 @@ class CryptoCurrencyStatusFactoryTest { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), - integrationId = StakingIntegrationID.Coin.Cardano.value, + integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, ), ) @@ -519,7 +519,7 @@ class CryptoCurrencyStatusFactoryTest { // Arrange val yieldBalance = YieldBalance.Data( stakingId = StakingID( - integrationId = StakingIntegrationID.Coin.Cardano.value, + integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = networkAddress.defaultAddress.value, ), source = StatusSource.ACTUAL, @@ -532,7 +532,7 @@ class CryptoCurrencyStatusFactoryTest { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), - integrationId = StakingIntegrationID.Coin.Cardano.value, + integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, ), ) @@ -608,7 +608,7 @@ class CryptoCurrencyStatusFactoryTest { // Arrange val yieldBalance = YieldBalance.Data( stakingId = StakingID( - integrationId = StakingIntegrationID.Coin.Cardano.value, + integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = networkAddress.defaultAddress.value, ), source = StatusSource.ACTUAL, @@ -621,7 +621,7 @@ class CryptoCurrencyStatusFactoryTest { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), - integrationId = StakingIntegrationID.Coin.Cardano.value, + integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, ), ) 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..befef5c116 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, @@ -95,29 +89,6 @@ internal class MockCurrenciesRepository( return token.getOrElse { e -> throw e } } - override fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow> { - 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/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt index 1a8e1bf3f0..f2eb68dbdd 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt @@ -4,15 +4,13 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.test.utils.assertEither -import com.tangem.common.test.utils.assertEitherRight import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.models.staking.StakingID import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -20,6 +18,8 @@ import com.tangem.domain.tokens.wallet.FetchingSource.* import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher +import com.tangem.test.core.assertEither +import com.tangem.test.core.assertEitherRight import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest @@ -385,7 +385,9 @@ internal class WalletBalanceFetcherTest { val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() val stakingId = Either.Left( - StakingIdFactory.Error.UnableToGetAddress(integrationId = StakingIntegrationID.EthereumToken.Polygon), + StakingIdFactory.Error.UnableToGetAddress( + integrationId = StakingIntegrationID.StakeKit.EthereumToken.Polygon, + ), ) every { currenciesRepository.getCardTypesResolver(userWalletId = userWalletId) } returns cardTypesResolver @@ -425,7 +427,9 @@ internal class WalletBalanceFetcherTest { val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() val ethereumStakingId = Either.Left( - StakingIdFactory.Error.UnableToGetAddress(integrationId = StakingIntegrationID.EthereumToken.Polygon), + StakingIdFactory.Error.UnableToGetAddress( + integrationId = StakingIntegrationID.StakeKit.EthereumToken.Polygon, + ), ) val stellarStakingId = Either.Left(StakingIdFactory.Error.UnsupportedCurrency) diff --git a/domain/transaction/detekt-baseline-debug.xml b/domain/transaction/detekt-baseline-debug.xml new file mode 100644 index 0000000000..40a21e2f39 --- /dev/null +++ b/domain/transaction/detekt-baseline-debug.xml @@ -0,0 +1,14 @@ + + + + + BooleanPropertyNaming:SendTransactionUseCase.kt$SendTransactionUseCase$val linkedTerminal = cardSdkConfigRepository.isLinkedTerminal() + BooleanPropertyNaming:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$val current = isCurrentAddress(addressToValidate) + MultilineLambdaItParameter:AssociateAssetUseCase.kt$AssociateAssetUseCase${ val network = currency.network it.network.id == network.id && it.network.derivationPath == network.derivationPath } + NamedArguments:SendTransactionUseCase.kt$SendTransactionUseCase$invoke(listOf(txData), userWallet, network, TransactionSender.MultipleTransactionSendMode.DEFAULT) + NamedArguments:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$validateAddressInternal( userWalletId, network, address, isCurrentAddress = { toValidate -> currencyAddresses?.any { it.value == toValidate } ?: true }, ) + NamedArguments:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$validateAddressInternal( userWalletId, network, address, isCurrentAddress = { toValidate -> senderAddresses.any { it.address == toValidate } }, ) + NullableBooleanCheck:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$currencyAddresses?.any { it.value == toValidate } ?: true + UnnecessaryLet:RetryIncompleteTransactionUseCase.kt$RetryIncompleteTransactionUseCase$let { raise(IncompleteTransactionError.SendError(it)) } + + diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt index f0bfdcf442..a148692971 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt @@ -7,6 +7,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee 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 interface FeeRepository { @@ -20,5 +21,5 @@ interface FeeRepository { transactionData: TransactionData, ): TransactionFee - suspend fun getEthereumFeeWithoutGas(userWallet: UserWallet, cryptoCurrency: CryptoCurrency): Fee.Ethereum + suspend fun getEthereumFeeWithoutGas(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Fee.Ethereum } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt index 5c26d2efdd..17b9fc38cc 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -142,8 +142,8 @@ class SendTransactionUseCase( * Sends tx hash to backend for specific transaction types */ private suspend fun sendHashToBackendIfNeeded(transaction: TransactionData, txHash: String) { - (transaction as? TransactionData.Uncompiled)?.let { - val extras = it.extras + (transaction as? TransactionData.Uncompiled)?.let { tx -> + val extras = tx.extras when (extras) { is EthereumTransactionExtras -> { val txType = when (extras.callData) { diff --git a/domain/visa/build.gradle.kts b/domain/visa/build.gradle.kts index e3bc73d03b..22faf074b1 100644 --- a/domain/visa/build.gradle.kts +++ b/domain/visa/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(deps.spongecastle.core) /** Libs - Other */ + implementation(deps.timber) implementation(deps.jodatime) implementation(deps.androidx.paging.runtime) implementation(deps.moshi) diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt index b42e77c698..1a31841f25 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt @@ -1,9 +1,12 @@ package com.tangem.domain.pay +import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.serialization.Serializable @Serializable data class TangemPayDetailsConfig( + val cardId: String, + val cardFrozenState: TangemPayCardFrozenState, val customerWalletAddress: String, val cardNumberEnd: String, val chainId: Int, diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt index ec42fc56c5..c94c767e02 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt @@ -61,6 +61,7 @@ sealed class VisaApiError( fun isUnknown() = this is UnknownWithoutCode || this is Unknown data object RefreshTokenExpired : VisaApiError(104004001) + data object NotPaeraCustomer : VisaApiError(104004002) companion object { fun fromBackendError(backendErrorCode: Int): VisaApiError { diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayAuthTokens.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayAuthTokens.kt new file mode 100644 index 0000000000..eb704a588a --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayAuthTokens.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.visa.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable + +@Serializable +@JsonClass(generateAdapter = true) +data class TangemPayAuthTokens( + @Json(name = "access_token") val accessToken: String, + @Json(name = "expires_at") val expiresAt: Long, + @Json(name = "refresh_token") val refreshToken: String, + @Json(name = "refresh_expires_at") val refreshExpiresAt: Long, +) + +fun TangemPayAuthTokens.getAuthHeader(): String { + return "Bearer $accessToken" +} \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayCardFrozenState.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayCardFrozenState.kt new file mode 100644 index 0000000000..6cbdffa944 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayCardFrozenState.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.visa.model + +import kotlinx.serialization.Serializable + +@Serializable +sealed class TangemPayCardFrozenState { + data object Pending : TangemPayCardFrozenState() + data object Frozen : TangemPayCardFrozenState() + data object Unfrozen : TangemPayCardFrozenState() +} \ No newline at end of file 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..28fc980690 --- /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: TangemPayAuthTokens) \ 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..1552db31bb 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,40 +1,63 @@ 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 localAmount: SerializedBigDecimal?, + val localCurrency: SerializedCurrency?, val enrichedMerchantName: String?, val merchantName: String, val enrichedMerchantCategory: String?, - val merchantCategory: String, + val merchantCategory: String?, val status: Status, 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, + val description: String?, + ) : 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..4b6c93492d 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.VisaAuthTokens +import com.tangem.domain.visa.model.TangemPayAuthTokens +import com.tangem.domain.visa.model.TangemPayInitialCredentials interface TangemPayAuthDataSource { - suspend fun generateNewAuthTokens(address: String, cardId: String): Either + suspend fun produceInitialCredentials(cardId: String): Either - suspend fun refreshAuthTokens(refreshToken: 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/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 9937d2e1ff..7f1092646f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -1,5 +1,6 @@ package com.tangem.domain.pay.model +import com.tangem.domain.visa.model.TangemPayCardFrozenState import java.math.BigDecimal data class MainScreenCustomerInfo( @@ -15,7 +16,8 @@ data class CustomerInfo( data class ProductInstance( val id: String, - val status: String, + val cardId: String, + val cardFrozenState: TangemPayCardFrozenState, ) data class CardInfo( 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/model/TangemPayCustomerInfoError.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCustomerInfoError.kt new file mode 100644 index 0000000000..7fda6cfe51 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCustomerInfoError.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.pay.model + +sealed interface TangemPayCustomerInfoError { + + data object UnavailableError : TangemPayCustomerInfoError + data object RefreshNeededError : TangemPayCustomerInfoError + data object UnknownError : TangemPayCustomerInfoError +} \ 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 deleted file mode 100644 index 39b0e8edfa..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.pay.repository - -import arrow.core.Either -import com.tangem.core.error.UniversalError -import com.tangem.domain.pay.model.TangemPayCardBalance -import com.tangem.domain.pay.model.TangemPayCardDetails - -interface CardDetailsRepository { - - suspend fun getCardBalance(): Either - - suspend fun revealCardDetails(): Either -} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt index 01d3a268d7..3abf5c2b0e 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.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.models.wallet.UserWalletId import com.tangem.domain.pay.KycStartInfo interface KycRepository { @@ -9,5 +10,5 @@ interface KycRepository { /** * Returns KYC data to start or continue the survey */ - suspend fun getKycStartInfo(): Either + suspend fun getKycStartInfo(userWalletId: UserWalletId): 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..0010b99546 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 @@ -2,19 +2,27 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo -import com.tangem.domain.pay.model.MainScreenCustomerInfo +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.visa.error.VisaApiError +import kotlinx.coroutines.Job interface OnboardingRepository { suspend fun validateDeeplink(link: String): Either - suspend fun getCustomerInfo(): Either + suspend fun isTangemPayInitialDataProduced(userWalletId: UserWalletId): Boolean - suspend fun createOrder(): Either + suspend fun produceInitialData(userWalletId: UserWalletId) - /** - * Returns only if the user already authorised at least once - */ - suspend fun getMainScreenCustomerInfo(): Either + suspend fun getCustomerInfo(userWalletId: UserWalletId): Either + + suspend fun createOrder(userWalletId: UserWalletId): Job + + suspend fun clearOrderId(userWalletId: UserWalletId) + suspend fun getOrderId(userWalletId: UserWalletId): String? + suspend fun getOrderStatus(userWalletId: UserWalletId, orderId: String): Either + suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either + fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt new file mode 100644 index 0000000000..73a4b8e035 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +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.visa.model.TangemPayCardFrozenState +import kotlinx.coroutines.flow.Flow + +interface TangemPayCardDetailsRepository { + + suspend fun getCardBalance(userWalletId: UserWalletId): Either + + suspend fun revealCardDetails(userWalletId: UserWalletId): Either + + suspend fun setPin(userWalletId: UserWalletId, pin: String): Either + + suspend fun isAddToWalletDone(userWalletId: UserWalletId): Either + + suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either + + suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either + suspend fun unfreezeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either + + fun cardFrozenState(cardId: String): Flow + suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? +} \ No newline at end of file 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..68ca205647 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ProduceTangemPayInitialDataUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.OnboardingRepository + +class ProduceTangemPayInitialDataUseCase( + private val repository: OnboardingRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either { + return catch { + repository.produceInitialData(userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayIssueOrderUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayIssueOrderUseCase.kt deleted file mode 100644 index 6127e5225b..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayIssueOrderUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.domain.pay.usecase - -import arrow.core.Either -import com.tangem.core.error.UniversalError -import com.tangem.domain.pay.repository.OnboardingRepository - -class TangemPayIssueOrderUseCase( - private val repository: OnboardingRepository, -) { - - suspend operator fun invoke(): Either = repository.createOrder() -} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt index 1f49a481e2..1d3d10d652 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt @@ -1,7 +1,19 @@ package com.tangem.domain.pay.usecase +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.catch +import arrow.core.right +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.MainScreenCustomerInfo +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayCustomerInfoError import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import timber.log.Timber + +private const val TAG = "TangemPayMainScreenCustomerInfoUseCase" /** * Returns tangem pay customer info for the main screen banner @@ -11,5 +23,96 @@ class TangemPayMainScreenCustomerInfoUseCase( private val repository: OnboardingRepository, ) { - suspend operator fun invoke(): MainScreenCustomerInfo? = repository.getMainScreenCustomerInfo().getOrNull() + suspend operator fun invoke( + userWalletId: UserWalletId, + ): Either = catch( + block = { + repository.checkCustomerWallet(userWalletId) + .fold( + ifLeft = { TangemPayCustomerInfoError.UnknownError.left() }, + ifRight = { hasTangemPay -> + if (hasTangemPay) { + proceedWithPaeraCustomerResult(userWalletId) + } else { + TangemPayCustomerInfoError.UnknownError.left() // ignore if there's no TangemPay + } + }, + ) + }, + catch = { error -> + Timber.tag(TAG).e(error) + TangemPayCustomerInfoError.UnknownError.left() + }, + ) + + private suspend fun proceedWithPaeraCustomerResult( + userWalletId: UserWalletId, + ): Either { + val orderId = repository.getOrderId(userWalletId) + return if (orderId != null) { + proceedWithOrderId(userWalletId = userWalletId, orderId = orderId) + } else { + proceedWithoutOrder(userWalletId = userWalletId) + } + } + + private suspend fun proceedWithoutOrder( + userWalletId: UserWalletId, + ): Either { + return repository.getCustomerInfo(userWalletId) + .mapLeft { error -> error.mapErrorForCustomer() } + .map { customerInfo -> + if (customerInfo.cardInfo == null) { + // If order id wasn't saved -> start order creation and get customer info + repository.createOrder(userWalletId) + } + MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.UNKNOWN) + } + } + + private suspend fun proceedWithOrderId( + userWalletId: UserWalletId, + orderId: String, + ): Either { + return repository.getOrderStatus(userWalletId, orderId = orderId) + .fold( + ifLeft = { error -> + error.mapErrorForCustomer().left() + }, + ifRight = { orderStatus -> + when (orderStatus) { + // Kyc is passed and user waits for order creation -> no need to get customer info + OrderStatus.NEW, + OrderStatus.PROCESSING, + -> MainScreenCustomerInfo( + info = CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null), + orderStatus = orderStatus, + ).right() + + // Order was created/cancelled -> clear order id and get customer info + OrderStatus.COMPLETED, + OrderStatus.CANCELED, + OrderStatus.UNKNOWN, + -> { + repository.clearOrderId(userWalletId) + // If order was cancelled -> start order creation + if (orderStatus == OrderStatus.CANCELED) repository.createOrder(userWalletId) + repository.getCustomerInfo(userWalletId = userWalletId) + .mapLeft { it.mapErrorForCustomer() } + .map { customerInfo -> + MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus) + } + } + } + }, + ) + } + + private fun VisaApiError.mapErrorForCustomer(): TangemPayCustomerInfoError { + return if (this !is VisaApiError.NotPaeraCustomer) { + TangemPayCustomerInfoError.UnavailableError + } else { + TangemPayCustomerInfoError.UnknownError + } + } } \ 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/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt index 7915f1b83d..50bf30ba9e 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/repository/TangemPayTxHistoryRepository.kt @@ -1,10 +1,12 @@ package com.tangem.domain.tangempay.repository +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext interface TangemPayTxHistoryRepository { fun getTxHistoryBatchFlow( + userWalletId: UserWalletId, batchSize: Int, context: TangemPayTxHistoryListBatchingContext, ): TangemPayTxHistoryListBatchFlow diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt index 20f95b2d40..fe2785bc3c 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/SetVisaPinCodeUseCase.kt @@ -9,6 +9,7 @@ import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.model.VisaEncryptedPinCode import com.tangem.domain.visa.repository.VisaActivationRepository +import com.tangem.utils.coroutines.runSuspendCatching import java.security.Key import java.security.KeyFactory import java.security.PublicKey @@ -27,7 +28,7 @@ class SetVisaPinCodeUseCase(private val visaActivationRepositoryFactory: VisaAct visaCardId: VisaCardId, activationOrderId: String, pinCode: String, - ): Either = runCatching { + ): Either = runSuspendCatching { val visaActivationRepository = visaActivationRepositoryFactory.create(visaCardId) val rsaPublicKey = visaActivationRepository.getPinCodeRsaEncryptionPublicKey() val formattedPin = "24$pinCode${"f".repeat(n = 8)}FF" diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt index 251532eeea..835c2b07a4 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt @@ -2,6 +2,7 @@ package com.tangem.domain.visa.datasource import arrow.core.Either import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayAuthTokens import com.tangem.domain.visa.model.VisaAuthChallenge import com.tangem.domain.visa.model.VisaAuthSignedChallenge import com.tangem.domain.visa.model.VisaAuthTokens @@ -20,17 +21,16 @@ interface VisaAuthRemoteDataSource { suspend fun getCustomerWalletAuthChallenge( customerWalletAddress: String, + customerWalletId: String, ): Either suspend fun getTokenWithCustomerWallet( sessionId: String, signature: String, nonce: String, - ): Either + ): Either - suspend fun refreshCustomerWalletAuthTokens( - refreshToken: VisaAuthTokens.RefreshToken, - ): Either + suspend fun refreshCustomerWalletAuthTokens(refreshToken: String): Either suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either 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/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt index 1ccecda5d1..8bcf3eb116 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt @@ -16,14 +16,14 @@ import com.tangem.utils.extensions.mapNotNullValues sealed class WcAnalyticEvents( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = WC_CATEGORY_NAME, event = event, params = params) { object ScreenOpened : WcAnalyticEvents(event = "WC Screen Opened") class NewPairInitiated(source: WcPairRequest.Source) : WcAnalyticEvents( event = "Session Initiated", params = mapOf( - AnalyticsParam.Key.SOURCE to when (source) { + AnalyticsParam.SOURCE to when (source) { WcPairRequest.Source.QR -> "QR" WcPairRequest.Source.DEEPLINK -> "DeepLink" WcPairRequest.Source.CLIPBOARD -> "Clipboard" @@ -57,8 +57,8 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Session Failed", params = mapOf( - AnalyticsParam.Key.ERROR_CODE to errorCode, - AnalyticsParam.Key.ERROR_DESCRIPTION to errorMessage, + AnalyticsParam.ERROR_CODE to errorCode, + AnalyticsParam.ERROR_DESCRIPTION to errorMessage, ), ) @@ -69,9 +69,9 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "dApp Connected", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to sessionProposal.dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to sessionProposal.dAppMetaData.url, - AnalyticsParam.Key.BLOCKCHAIN to sessionForApprove.network.joinToString(",") { it.name }, + AnalyticsParam.DAPP_NAME to sessionProposal.dAppMetaData.name, + AnalyticsParam.DAPP_URL to sessionProposal.dAppMetaData.url, + AnalyticsParam.BLOCKCHAIN to sessionForApprove.network.joinToString(",") { it.name }, DOMAIN_VERIFICATION to securityStatus.toAnalyticVerificationStatus(), ), ) @@ -82,16 +82,16 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "dApp Connection Failed", params = mapOf( - AnalyticsParam.Key.ERROR_CODE to errorCode, - AnalyticsParam.Key.ERROR_DESCRIPTION to errorMessage, + AnalyticsParam.ERROR_CODE to errorCode, + AnalyticsParam.ERROR_DESCRIPTION to errorMessage, ), ) class SessionDisconnected(dAppMetaData: WcAppMetaData) : WcAnalyticEvents( event = "dApp Disconnected", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to dAppMetaData.url, + AnalyticsParam.DAPP_NAME to dAppMetaData.name, + AnalyticsParam.DAPP_URL to dAppMetaData.url, ), ) @@ -101,10 +101,10 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Transaction Details Opened", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to rawRequest.dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to rawRequest.dAppMetaData.url, - AnalyticsParam.Key.METHOD_NAME to rawRequest.request.method, - AnalyticsParam.Key.BLOCKCHAIN to network.name, + AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name, + AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url, + AnalyticsParam.METHOD_NAME to rawRequest.request.method, + AnalyticsParam.BLOCKCHAIN to network.name, ), ) @@ -116,12 +116,12 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Signature Request Received", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to rawRequest.dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to rawRequest.dAppMetaData.url, - AnalyticsParam.Key.METHOD_NAME to rawRequest.request.method, - AnalyticsParam.Key.BLOCKCHAIN to network.name, - AnalyticsParam.Key.EMULATION_STATUS to emulationStatus?.status, - AnalyticsParam.Key.TYPE to securityStatus.toAnalyticVerificationStatus(), + AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name, + AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url, + AnalyticsParam.METHOD_NAME to rawRequest.request.method, + AnalyticsParam.BLOCKCHAIN to network.name, + AnalyticsParam.EMULATION_STATUS to emulationStatus?.status, + AnalyticsParam.TYPE to securityStatus.toAnalyticVerificationStatus(), ).mapNotNullValues { it.value }, ) { enum class EmulationStatus(val status: String) { @@ -138,11 +138,11 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Signature Request Handled", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to rawRequest.dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to rawRequest.dAppMetaData.url, - AnalyticsParam.Key.METHOD_NAME to rawRequest.request.method, - AnalyticsParam.Key.BLOCKCHAIN to network.name, - AnalyticsParam.Key.TYPE to securityStatus.toAnalyticVerificationStatus(), + AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name, + AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url, + AnalyticsParam.METHOD_NAME to rawRequest.request.method, + AnalyticsParam.BLOCKCHAIN to network.name, + AnalyticsParam.TYPE to securityStatus.toAnalyticVerificationStatus(), ), ) @@ -154,12 +154,12 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Signature Request Failed", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to rawRequest.dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to rawRequest.dAppMetaData.url, - AnalyticsParam.Key.METHOD_NAME to rawRequest.request.method, - AnalyticsParam.Key.BLOCKCHAIN to network.name, - AnalyticsParam.Key.ERROR_CODE to errorCode, - AnalyticsParam.Key.ERROR_DESCRIPTION to errorMessage, + AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name, + AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url, + AnalyticsParam.METHOD_NAME to rawRequest.request.method, + AnalyticsParam.BLOCKCHAIN to network.name, + AnalyticsParam.ERROR_CODE to errorCode, + AnalyticsParam.ERROR_DESCRIPTION to errorMessage, ), ) @@ -171,12 +171,12 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Signature Request Received with Failed", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to rawRequest.dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to rawRequest.dAppMetaData.url, - AnalyticsParam.Key.METHOD_NAME to rawRequest.request.method, - AnalyticsParam.Key.BLOCKCHAIN to blockchain, - AnalyticsParam.Key.ERROR_CODE to errorCode, - AnalyticsParam.Key.ERROR_DESCRIPTION to errorMessage, + AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name, + AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url, + AnalyticsParam.METHOD_NAME to rawRequest.request.method, + AnalyticsParam.BLOCKCHAIN to blockchain, + AnalyticsParam.ERROR_CODE to errorCode, + AnalyticsParam.ERROR_DESCRIPTION to errorMessage, ), ) @@ -185,7 +185,7 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Button - Sign", params = mapOf( - AnalyticsParam.Key.METHOD_NAME to rawRequest.request.method, + AnalyticsParam.METHOD_NAME to rawRequest.request.method, ), ) @@ -194,7 +194,7 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Button - Cancel", params = mapOf( - AnalyticsParam.Key.TYPE to type.type, + AnalyticsParam.TYPE to type.type, ), ) { enum class Type(val type: String) { @@ -212,8 +212,8 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Button - Disconnect", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to session.sdkModel.appMetaData.name, - AnalyticsParam.Key.DAPP_URL to session.sdkModel.appMetaData.url, + AnalyticsParam.DAPP_NAME to session.sdkModel.appMetaData.name, + AnalyticsParam.DAPP_URL to session.sdkModel.appMetaData.url, ), ) @@ -224,13 +224,13 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Notice - Security Alert", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to dAppMetaData.name, - AnalyticsParam.Key.DAPP_URL to dAppMetaData.url, - AnalyticsParam.Key.SOURCE to when (source) { + AnalyticsParam.DAPP_NAME to dAppMetaData.name, + AnalyticsParam.DAPP_URL to dAppMetaData.url, + AnalyticsParam.SOURCE to when (source) { Source.Domain -> "Domain" Source.SmartContract -> "Smart Contract" }, - AnalyticsParam.Key.TYPE to securityStatus.toAnalyticVerificationStatus(), + AnalyticsParam.TYPE to securityStatus.toAnalyticVerificationStatus(), ), ) { enum class Source { Domain, SmartContract } @@ -241,7 +241,7 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Solana Large Transaction", params = mapOf( - AnalyticsParam.Key.DAPP_NAME to dappName, + AnalyticsParam.DAPP_NAME to dappName, ), ) @@ -250,7 +250,7 @@ sealed class WcAnalyticEvents( ) : WcAnalyticEvents( event = "Solana Large Transaction Status", params = mapOf( - AnalyticsParam.Key.STATUS to status.value, + AnalyticsParam.STATUS to status.value, ), ) { enum class Status(val value: String) { diff --git a/domain/wallets/build.gradle.kts b/domain/wallets/build.gradle.kts index 9091554999..ad9162e60a 100644 --- a/domain/wallets/build.gradle.kts +++ b/domain/wallets/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { // region Core modules implementation(projects.core.res) implementation(projects.core.utils) + implementation(projects.core.analytics) // endregion // region Domain modules @@ -30,6 +31,7 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) implementation(projects.domain.notifications.models) + implementation(projects.domain.demo.models) // endregion // region Tangem libraries diff --git a/domain/wallets/detekt-baseline-debug.xml b/domain/wallets/detekt-baseline-debug.xml new file mode 100644 index 0000000000..149134a54c --- /dev/null +++ b/domain/wallets/detekt-baseline-debug.xml @@ -0,0 +1,45 @@ + + + + + BooleanPropertyNaming:DefaultUserWalletsSyncDelegate.kt$DefaultUserWalletsSyncDelegate$private val useNewRepository: Boolean + BooleanPropertyNaming:DeleteWalletUseCase.kt$DeleteWalletUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:GenerateWalletNameUseCase.kt$GenerateWalletNameUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:GetSavedWalletsCountUseCase.kt$GetSavedWalletsCountUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:GetSelectedWalletSyncUseCase.kt$GetSelectedWalletSyncUseCase$private val useNewRepository: Boolean = false + BooleanPropertyNaming:GetSelectedWalletUseCase.kt$GetSelectedWalletUseCase$private val useNewRepository: Boolean = false + BooleanPropertyNaming:GetUserWalletUseCase.kt$GetUserWalletUseCase$private val useNewListRepository: Boolean + BooleanPropertyNaming:GetWalletNamesUseCase.kt$GetWalletNamesUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:GetWalletsUseCase.kt$GetWalletsUseCase$private val useNewListRepository: Boolean + BooleanPropertyNaming:HotWalletAccessCodeAttemptsRepository.kt$HotWalletAccessCodeAttemptsRepository.AttemptId$val auth: Boolean + BooleanPropertyNaming:HotWalletPasswordRequester.kt$HotWalletPasswordRequester.AttemptRequest$val authMode: Boolean + BooleanPropertyNaming:IsNeedToBackupUseCase.kt$IsNeedToBackupUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:IsWalletAlreadySavedUseCase.kt$IsWalletAlreadySavedUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:SaveWalletUseCase.kt$SaveWalletUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:SaveWalletUseCase.kt$SaveWalletUseCase$val newUserWallet = userWalletsListRepository.userWalletsSync().none { it.walletId == userWallet.walletId } + BooleanPropertyNaming:SelectWalletUseCase.kt$SelectWalletUseCase$private val useNewRepository: Boolean + BooleanPropertyNaming:UpdateWalletUseCase.kt$UpdateWalletUseCase$private val useNewRepository: Boolean + MaxChainedCallsOnSameLine:UserWalletExtensions.kt$wallets.orEmpty().first { it.curve == primaryCurve }.derivedKeys.keys.any { it == dp } + MultilineLambdaItParameter:ColdUserWalletBuilder.kt$ColdUserWalletBuilder${ UserWallet.Cold( walletId = it, name = generateWalletNameUseCase( card = card, productType = productType, isStartToCoin = cardTypesResolver.isStart2Coin(), ), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), hasBackupError = hasBackupError, ) } + MultilineLambdaItParameter:HotUserWalletBuilder.kt$HotUserWalletBuilder${ MobileWallet( publicKey = it.seedKey.publicKey, chainCode = it.seedKey.chainCode, curve = it.curve, derivedKeys = it.publicKeys, ) } + MultilineLambdaItParameter:HotUserWalletBuilder.kt$HotUserWalletBuilder${ val derivationPath = it.derivationPath(DerivationStyle.V3) ?: return@mapNotNull null if (it == Blockchain.Cardano) { val extendedDerivationPath = CardanoUtils.extendedDerivationPath(derivationPath) listOf(derivationPath, extendedDerivationPath) } else { listOf(derivationPath) } } + MultilineLambdaItParameter:SaveWalletUseCase.kt$SaveWalletUseCase${ return when (it) { is UserWalletsListError.WalletAlreadySaved -> SaveWalletError.WalletAlreadySaved( it.messageResId, ) else -> SaveWalletError.DataError(it.messageResId) }.left() } + MultilineLambdaItParameter:SelectWalletUseCase.kt$SelectWalletUseCase${ reduxStateHolder.onUserWalletSelected(it) it } + MultilineLambdaItParameter:UpdateWalletUseCase.kt$UpdateWalletUseCase${ when (it) { is SaveWalletError.DataError -> DataError( IllegalStateException("Failed to update wallet: ${it.messageId}"), ) is SaveWalletError.WalletAlreadySaved -> UpdateWalletError.NameAlreadyExists } } + NestedScopeFunctions:ColdUserWalletBuilder.kt$ColdUserWalletBuilder$let { UserWallet.Cold( walletId = it, name = generateWalletNameUseCase( card = card, productType = productType, isStartToCoin = cardTypesResolver.isStart2Coin(), ), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), hasBackupError = hasBackupError, ) } + NoNameShadowing:SaveWalletUseCase.kt$SaveWalletUseCase$userWallet + NonBooleanPropertyPrefixedWithIs:GetExtendedPublicKeyForCurrencyUseCase.kt$GetExtendedPublicKeyForCurrencyUseCase$val isHdKey = walletManager.wallet.publicKey.derivationType?.hdKey + NonBooleanPropertyPrefixedWithIs:UserWalletsListManagerExtensions.kt$/** * Indicates that the [UserWalletsListManager] is locked * * @return If [UserWalletsListManager] not implements [UserWalletsListManager.Lockable] returns [Flow] which * produces only one false value * * @see UserWalletsListManager.Lockable.isLocked * */ val UserWalletsListManager.isLocked: Flow<Boolean> get() = asLockable()?.lockedState ?: flowOf(false) + NullableToStringCall:GenerateBuyTangemCardLinkUseCase.kt$GenerateBuyTangemCardLinkUseCase$$id + NullableToStringCall:UpdateWalletUseCase.kt$UpdateWalletUseCase$${it.messageId} + ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$AllKeysInvalidated : UserWalletsListError + ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$BiometricsAuthenticationDisabled : UserWalletsListError + ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$NoUserWalletSelected : UserWalletsListError + ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$NotAllUserWalletsUnlocked : UserWalletsListError + ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$WalletAlreadySaved : UserWalletsListError + RedundantSuspendModifier:GetHotWalletContextualUnlockUseCase.kt$GetHotWalletContextualUnlockUseCase$suspend + SuspendFunSwallowedCancellation:RenameWalletUseCase.kt$RenameWalletUseCase$runCatching + UnsafeCallOnNullableType:GetWalletsUseCase.kt$GetWalletsUseCase$userWalletsListRepository.userWallets.value!! + UnsafeCallOnNullableType:UserWalletIdBuilder.kt$UserWalletIdBuilder.Companion$UserWalletIdBuilder( publicKey = publicKey, ).build()!! + + diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/analytics/Settings.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/Settings.kt similarity index 71% rename from features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/analytics/Settings.kt rename to domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/Settings.kt index 51c265394a..6bc640e1e2 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/analytics/Settings.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/Settings.kt @@ -1,11 +1,11 @@ -package com.tangem.feature.walletsettings.analytics +package com.tangem.domain.wallets.analytics import com.tangem.core.analytics.models.AnalyticsEvent -internal sealed class Settings( +sealed class Settings( category: String = "Settings", event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category, event, params) { data object ButtonCreateBackup : Settings(event = "Button - Create Backup") diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt new file mode 100644 index 0000000000..4a46695ee3 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt @@ -0,0 +1,154 @@ +package com.tangem.domain.wallets.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION +import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS + +sealed class WalletSettingsAnalyticEvents( + category: String = "Settings / Wallet Settings", + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category, event, params) { + + data class NftToggleSwitch(val enabled: AnalyticsParam.OnOffState) : WalletSettingsAnalyticEvents( + event = "NFT toggle switch", + params = mapOf(STATUS to enabled.value), + ) + + object WalletSettingsScreenOpened : WalletSettingsAnalyticEvents( + event = "Wallet Settings Screen Opened", + ) + + object ButtonBackup : WalletSettingsAnalyticEvents( + event = "Button - Backup", + ) + + data class ButtonAccessCode( + private val isCodeSet: Boolean, + ) : WalletSettingsAnalyticEvents( + event = "Button - Access Code", + params = mapOf(ACTION to if (isCodeSet) "Changing" else "Set"), + ) + + data class BackupScreenOpened( + val isManualBackupEnabled: Boolean, + ) : WalletSettingsAnalyticEvents( + event = "Backup Screen Opened", + params = mapOf("Manual Backup" to if (isManualBackupEnabled) "Enabled" else "Disabled"), + ) + + object ButtonRecoveryPhrase : WalletSettingsAnalyticEvents( + event = "Button - Recovery phrase", + ) + + data class NoticeBackupFirst( + val source: String, + val action: Action, + ) : WalletSettingsAnalyticEvents( + event = "Notice - Backup First", + params = mapOf( + AnalyticsParam.Key.SOURCE to source, + AnalyticsParam.Key.ACTION to action.value, + ), + ) { + enum class Action(val value: String) { + AccessCode("Access Code"), + Upgrade("Upgrade"), + Remove("Remove"), + } + } + + object ButtonHardwareUpdate : WalletSettingsAnalyticEvents( + event = "Button - Hardware Update", + ) + + object HardwareUpgradeScreenOpened : WalletSettingsAnalyticEvents( + event = "Hardware Upgrade Screen Opened", + ) + + object ButtonCreateNewWallet : WalletSettingsAnalyticEvents( + event = "Button - Create New Wallet", + ) + + object ButtonUpgradeCurrent : WalletSettingsAnalyticEvents( + event = "Button - Upgrade Current", + ) + + object CreateWalletScreenOpened : WalletSettingsAnalyticEvents( + event = "Create Wallet Screen Opened", + ) + + object HardwareBackupScreenOpened : WalletSettingsAnalyticEvents( + event = "Hardware Backup Screen Opened", + ) + + data class RecoveryPhraseScreenInfo( + val source: String, + val action: String, + ) : WalletSettingsAnalyticEvents( + event = "Recovery Phrase Screen Info", + params = mapOf( + AnalyticsParam.Key.SOURCE to source, + AnalyticsParam.Key.ACTION to action, + ), + ) + + data class RecoveryPhraseScreen( + val source: String, + val action: String, + ) : WalletSettingsAnalyticEvents( + event = "Recovery Phrase Screen", + params = mapOf( + AnalyticsParam.Key.SOURCE to source, + AnalyticsParam.Key.ACTION to action, + ), + ) + + data class RecoveryPhraseCheck( + val source: String, + val action: String, + ) : WalletSettingsAnalyticEvents( + event = "Recovery Phrase Check", + params = mapOf( + AnalyticsParam.Key.SOURCE to source, + AnalyticsParam.Key.ACTION to action, + ), + ) + + data class BackupCompleteScreen( + val source: String, + val action: String, + ) : WalletSettingsAnalyticEvents( + event = "Backup Complete Screen", + params = mapOf( + AnalyticsParam.Key.SOURCE to source, + AnalyticsParam.Key.ACTION to action, + ), + ) + + data class AccessCodeScreenOpened( + val source: String, + ) : WalletSettingsAnalyticEvents( + event = "Access Code Screen Opened", + params = mapOf(AnalyticsParam.Key.SOURCE to source), + ) + + data class ReEnterAccessCodeScreen( + val source: String, + ) : WalletSettingsAnalyticEvents( + event = "Re-enter Access Code Screen", + params = mapOf(AnalyticsParam.Key.SOURCE to source), + ) + + object ButtonStartUpgrade : WalletSettingsAnalyticEvents( + event = "Button - Start Upgrade", + ) + + enum class RecoveryPhraseScreenAction(val value: String) { + Upgrade("Upgrade"), + Backup("Backup"), + AccessCode("Access Code"), + Remove("Remove"), + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt index b1298548e6..9c9e04a643 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/ColdUserWalletBuilder.kt @@ -1,7 +1,6 @@ package com.tangem.domain.wallets.builder import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase @@ -16,9 +15,6 @@ class ColdUserWalletBuilder @AssistedInject constructor( private var backupCardsIds: Set = emptySet() private var hasBackupError: Boolean = false - private val CardDTO.isBackupNotAllowed: Boolean - get() = !settings.isBackupAllowed - /** * DANGEROUS!!! * [backupCardsIds] will be non-empty list if card is backed up on current device. @@ -44,8 +40,8 @@ class ColdUserWalletBuilder @AssistedInject constructor( UserWallet.Cold( walletId = it, name = generateWalletNameUseCase( + card = card, productType = productType, - isBackupNotAllowed = card.isBackupNotAllowed, isStartToCoin = cardTypesResolver.isStart2Coin(), ), cardsInWallet = backupCardsIds.plus(card.cardId), diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt index 74055cd741..1047bb4f03 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt @@ -3,6 +3,7 @@ package com.tangem.domain.wallets.extension import com.tangem.blockchain.blockchains.cardano.CardanoUtils import com.tangem.blockchain.common.Blockchain import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.hasDerivation import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.config.curvesConfig @@ -25,4 +26,17 @@ fun UserWallet.hasDerivation(blockchain: Blockchain, derivationPath: String): Bo } } } -} \ No newline at end of file +} + +/** + * Check if accounts are supported for the [UserWallet] + */ +val UserWallet.isAccountsSupported + get() = when (this) { + is UserWallet.Hot -> true + is UserWallet.Cold -> { + with(this.cardTypesResolver) { + isMultiwalletAllowed() && !isWhiteWallet() + } + } + } \ No newline at end of file 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..2cbdf16c5c 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 @@ -43,7 +43,7 @@ interface WalletsRepository { suspend fun acceptSeedPhraseSecondNotification(userWalletId: UserWalletId) - suspend fun markWallet2WasCreated(userWalletId: UserWalletId) + suspend fun createWallet(userWalletId: UserWalletId) fun nftEnabledStatus(userWalletId: UserWalletId): Flow diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GenerateWalletNameUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GenerateWalletNameUseCase.kt index 696e8dc79f..55a32a38ba 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GenerateWalletNameUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GenerateWalletNameUseCase.kt @@ -1,5 +1,7 @@ package com.tangem.domain.wallets.usecase +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.requireUserWalletsSync import com.tangem.domain.models.scan.ProductType @@ -14,10 +16,14 @@ class GenerateWalletNameUseCase( private val useNewRepository: Boolean, ) { - operator fun invoke(productType: ProductType, isBackupNotAllowed: Boolean, isStartToCoin: Boolean): String { + private val CardDTO.isBackupNotAllowed: Boolean + get() = !settings.isBackupAllowed + + operator fun invoke(productType: ProductType, card: CardDTO, isStartToCoin: Boolean): String { val defaultName = getDefaultName( productType = productType, - isBackupNotAllowed = isBackupNotAllowed, + card = card, + isBackupNotAllowed = card.isBackupNotAllowed, isStartToCoin = isStartToCoin, ) @@ -55,7 +61,20 @@ class GenerateWalletNameUseCase( return defaultName } - private fun getDefaultName(productType: ProductType, isBackupNotAllowed: Boolean, isStartToCoin: Boolean): String { + private fun getDefaultName( + productType: ProductType, + card: CardDTO, + isBackupNotAllowed: Boolean, + isStartToCoin: Boolean, + ): String { + /** + * @workaround + * There were produced 20k Note demo cards that should work like multiwallet (except Onboarding) + * for that reasons we've just added some specific checks for their BatchId + */ + if (DemoConfig.isDemoNoteAsMultiwallet(card.cardId)) { + return "Wallet" + } return when (productType) { ProductType.Note -> "Note" ProductType.Twins -> "Twin" diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/HasSecuredWalletsUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/HasSecuredWalletsUseCase.kt new file mode 100644 index 0000000000..30fd74b65e --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/HasSecuredWalletsUseCase.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.common.wallets.UserWalletsListRepository + +class HasSecuredWalletsUseCase(private val userWalletsListRepository: UserWalletsListRepository) { + + suspend operator fun invoke(): Boolean { + return userWalletsListRepository.hasSecuredWallets() + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt new file mode 100644 index 0000000000..d225546c2d --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt @@ -0,0 +1,51 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.UnlockWalletError +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.wallets.repository.WalletsRepository + +/** + * Use case for unlocking a wallet using non-biometric methods. + * If the wallet is already unlocked, it does nothing. + * + * **Does not** return [UnlockWalletError.AlreadyUnlocked] as an error, since the wallet is already unlocked. + * + * @see UnlockWalletUseCase + */ +class NonBiometricUnlockWalletUseCase( + private val userWalletsListRepository: UserWalletsListRepository, + private val walletsRepository: WalletsRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either = either { + val userWallet = userWalletsListRepository.userWalletsSync() + .find { it.walletId == userWalletId } + ?: raise(UnlockWalletError.UserWalletNotFound) + + if (!userWallet.isLocked) { + return@either + } + + val method = when (userWallet) { + is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan() + is UserWallet.Hot -> UserWalletsListRepository.UnlockMethod.AccessCode + } + + userWalletsListRepository.unlock(userWalletId, method) + .onRight { + if (walletsRepository.useBiometricAuthentication()) { + // After successful unlock, set biometric lock if applicable + userWalletsListRepository.setLock( + userWalletId, + UserWalletsListRepository.LockMethod.Biometric, + ) + } + } + .bind() + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt new file mode 100644 index 0000000000..1f111425fc --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.utils.coroutines.runSuspendCatching +import timber.log.Timber + +/** + * Use case to sync wallet with remote + * +[REDACTED_AUTHOR] + */ +class SyncWalletWithRemoteUseCase( + private val walletsRepository: WalletsRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId) { + runSuspendCatching { walletsRepository.createWallet(userWalletId) } + .onFailure(Timber::e) + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt new file mode 100644 index 0000000000..61d4c4e10c --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.UnlockWalletError +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Use case for unlocking a wallet using biometric authentication. + * If biometric unlock fails, it falls back to non-biometric unlock methods. + * + * ** Does not** return [UnlockWalletError.AlreadyUnlocked] as an error, since the wallet is already unlocked. + * + * @see NonBiometricUnlockWalletUseCase + */ +class UnlockWalletUseCase( + private val userWalletsListRepository: UserWalletsListRepository, + private val nonBiometricUnlockWalletUseCase: NonBiometricUnlockWalletUseCase, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either = either { + userWalletsListRepository.unlock(userWalletId, UserWalletsListRepository.UnlockMethod.Biometric) + .mapLeft { error -> + when (error) { + UnlockWalletError.AlreadyUnlocked -> Unit + else -> nonBiometricUnlockWalletUseCase(userWalletId).bind() + } + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletsUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletsUseCase.kt index 4d46212807..3bbed19358 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletsUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletsUseCase.kt @@ -17,6 +17,7 @@ import com.tangem.domain.wallets.models.UnlockWalletsError * [REDACTED_AUTHOR] */ +@Deprecated("Use NonBiometricUnlockWalletUseCase after migrating to new wallets repository") class UnlockWalletsUseCase(private val userWalletsListManager: UserWalletsListManager) { suspend operator fun invoke(type: UnlockType = UnlockType.ANY): Either = either { diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt index e3b24f7f96..9a9da874ed 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt @@ -70,7 +70,7 @@ class YieldSupplyEstimateEnterFeeUseCase( if (estimatedFees.estimatedGasList.isEmpty()) return null val fee = feeRepository.getEthereumFeeWithoutGas( - userWallet = userWallet, + userWalletId = userWallet.walletId, cryptoCurrency = cryptoCurrency, ) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt index c9ee2d6a93..460440f4b4 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt @@ -2,14 +2,14 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import arrow.core.Either.Companion.catch -import com.tangem.domain.yield.supply.fixFee import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.quote.QuoteStatus -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.yield.supply.YieldSupplyConst.YIELD_SUPPLY_EVM_CONSTANT_GAS_LIMIT +import com.tangem.domain.yield.supply.fixFee import java.math.BigDecimal import java.math.RoundingMode @@ -23,16 +23,16 @@ class YieldSupplyGetCurrentFeeUseCase( ) { suspend operator fun invoke( - userWallet: UserWallet, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, ): Either = catch { - val feeWithoutGas = feeRepository.getEthereumFeeWithoutGas(userWallet, cryptoCurrencyStatus.currency) + val feeWithoutGas = feeRepository.getEthereumFeeWithoutGas(userWalletId, cryptoCurrencyStatus.currency) val fiatRate = cryptoCurrencyStatus.value.fiatRate ?: error("Fiat rate is missing") require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" } val nativeCryptoCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, networkId = cryptoCurrencyStatus.currency.network.id, derivationPath = cryptoCurrencyStatus.currency.network.derivationPath, ) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt index ed746b30a4..516452538c 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCase.kt @@ -6,7 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.quote.QuoteStatus -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.yield.supply.YieldSupplyRepository @@ -32,7 +32,7 @@ class YieldSupplyGetMaxFeeUseCase( ) { suspend operator fun invoke( - userWallet: UserWallet, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, ): Either = catch { val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token @@ -42,7 +42,7 @@ class YieldSupplyGetMaxFeeUseCase( require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" } val nativeCryptoCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, networkId = cryptoCurrencyStatus.currency.network.id, derivationPath = cryptoCurrencyStatus.currency.network.derivationPath, ) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetProtocolBalanceUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetProtocolBalanceUseCase.kt index 3bc5de3b71..ea8b134aa2 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetProtocolBalanceUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetProtocolBalanceUseCase.kt @@ -17,7 +17,7 @@ class YieldSupplyGetProtocolBalanceUseCase( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): Either = Either.catch { - requireNotNull(cryptoCurrency as CryptoCurrency.Token) + requireNotNull(cryptoCurrency as? CryptoCurrency.Token) yieldSupplyTransactionRepository.getEffectiveProtocolBalance( userWalletId = userWalletId, diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt index fb055caf3f..775081fd02 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt @@ -1,8 +1,8 @@ package com.tangem.domain.yield.supply.usecase -import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.anyDecimals import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -23,10 +23,14 @@ class YieldSupplyGetRewardsBalanceUseCase( ) { operator fun invoke(status: CryptoCurrencyStatus, appCurrency: AppCurrency): Flow = flow { - val amount: BigDecimal? = status.value.amount?.let { amt -> - status.value.fiatRate?.let { rate -> amt.multiply(rate) } + val cryptoAmount = status.value.amount + val fiatRate = status.value.fiatRate + + val amount = if (cryptoAmount != null && fiatRate != null) { + cryptoAmount.multiply(fiatRate) + } else { + return@flow } - if (amount == null) return@flow val tokenAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress ?: return@flow val apy = try { diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt index 9e6f8b109d..501e6c74f5 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt @@ -2,14 +2,14 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import arrow.core.Either.Companion.catch -import com.tangem.domain.yield.supply.fixFee import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.quote.QuoteStatus -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.yield.supply.YieldSupplyConst.YIELD_SUPPLY_EVM_CONSTANT_GAS_LIMIT +import com.tangem.domain.yield.supply.fixFee import java.math.BigDecimal import java.math.RoundingMode @@ -20,16 +20,16 @@ class YieldSupplyMinAmountUseCase( ) { suspend operator fun invoke( - userWallet: UserWallet, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, ): Either = catch { - val feeWithoutGas = feeRepository.getEthereumFeeWithoutGas(userWallet, cryptoCurrencyStatus.currency) + val feeWithoutGas = feeRepository.getEthereumFeeWithoutGas(userWalletId, cryptoCurrencyStatus.currency) val fiatRate = cryptoCurrencyStatus.value.fiatRate ?: error("Fiat rate is missing") require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" } val nativeCryptoCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, networkId = cryptoCurrencyStatus.currency.network.id, derivationPath = cryptoCurrencyStatus.currency.network.derivationPath, ) diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt index 38e789a341..94016321e9 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt @@ -34,7 +34,9 @@ class YieldSupplyEstimateEnterFeeUseCaseTest { private val blockAidGasEstimate: BlockAidGasEstimate = mockk() private val useCase = YieldSupplyEstimateEnterFeeUseCase(feeRepository, feeErrorResolver, blockAidGasEstimate) - private val userWallet: UserWallet = mockk() + private val userWallet: UserWallet = mockk { + every { walletId } returns mockk() + } private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { every { decimals } returns 18 } diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt index c140d8f35e..715c90c6a3 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt @@ -65,7 +65,7 @@ class YieldSupplyMinAmountUseCaseTest { val maxFeePerGas = BigInteger("158320679232") val fee = createEip1559Fee(maxFeePerGas) - coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet, token) } returns fee + coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet.walletId, token) } returns fee coEvery { currenciesRepository.getNetworkCoin( userWalletId = userWallet.walletId, @@ -88,7 +88,7 @@ class YieldSupplyMinAmountUseCaseTest { ), ) val expected = expectedMinAmount(maxFeePerGas, nativeFiatRate, tokenFiatRate, token.decimals) - val result = useCase(userWallet, tokenStatus).getOrNull() + val result = useCase(userWallet.walletId, tokenStatus).getOrNull() Truth.assertThat(result).isEqualTo(expected) } @@ -114,7 +114,7 @@ class YieldSupplyMinAmountUseCaseTest { ), ) val userWallet = createUserWallet() - val result = useCase(userWallet, tokenStatus) + val result = useCase(userWallet.walletId, tokenStatus) Truth.assertThat(result.isLeft()).isTrue() Truth.assertThat(result.leftOrNull()?.message).isEqualTo("Fiat rate is missing") } @@ -145,7 +145,7 @@ class YieldSupplyMinAmountUseCaseTest { val maxFeePerGas = BigInteger("158320679232") val fee = createEip1559Fee(maxFeePerGas) - coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet, token) } returns fee + coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet.walletId, token) } returns fee coEvery { currenciesRepository.getNetworkCoin( userWalletId = userWallet.walletId, @@ -157,7 +157,7 @@ class YieldSupplyMinAmountUseCaseTest { coEvery { quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) } returns null - val result = useCase(userWallet, tokenStatus) + val result = useCase(userWallet.walletId, tokenStatus) Truth.assertThat(result.isLeft()).isTrue() Truth.assertThat(result.leftOrNull()?.message).isEqualTo("Quotes for native coin are unavailable") } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 17e942ca63..0b4ac32038 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -22,7 +22,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: "detekt detektGoogleDebug --continue") + gradle(task: "detekt detektGoogleDebug detektDebug --continue") end desc "Run tests" @@ -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/detekt-baseline-debug.xml b/features/account/api/detekt-baseline-debug.xml new file mode 100644 index 0000000000..43b883bfc2 --- /dev/null +++ b/features/account/api/detekt-baseline-debug.xml @@ -0,0 +1,10 @@ + + + + + BooleanPropertyNaming:PortfolioFetcher.kt$PortfolioFetcher.Mode.All$val onlyMultiCurrency: Boolean + NonBooleanPropertyPrefixedWithIs:PortfolioSelectorComponent.kt$PortfolioSelectorController$/** * for some Feature specific filtering * combine and update with your Feature data and [PortfolioFetcher.data] */ val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean> + NonBooleanPropertyPrefixedWithIs:PortfolioSelectorComponent.kt$PortfolioSelectorController$val isAccountMode: Flow<Boolean> + UseSumOfInsteadOfFlatMapSize:PortfolioFetcher.kt$PortfolioFetcher.Data$flatten() + + 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..1884981a50 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 @@ -21,8 +21,18 @@ interface PortfolioFetcher { data class Data( val appCurrency: AppCurrency, val isBalanceHidden: Boolean, - val balances: Map, - ) + val balances: Map, + ) { + + val isSingleChoice: Boolean = balances.values + .map { it.accountsBalance.accountStatuses } + .flatten().size == 1 + + fun isSingleChoice(walletId: UserWalletId): Boolean = balances[walletId] + ?.accountsBalance + ?.accountStatuses + ?.size == 1 + } data class PortfolioBalance( val userWallet: UserWallet, diff --git a/features/account/impl/detekt-baseline-debug.xml b/features/account/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..dce6c95e71 --- /dev/null +++ b/features/account/impl/detekt-baseline-debug.xml @@ -0,0 +1,10 @@ + + + + + NonBooleanPropertyPrefixedWithIs:DefaultPortfolioSelectorController.kt$DefaultPortfolioSelectorController$override val isAccountMode: Flow<Boolean> by lazy { isAccountsModeEnabledUseCase() } + NonBooleanPropertyPrefixedWithIs:DefaultPortfolioSelectorController.kt$DefaultPortfolioSelectorController$override val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean> = MutableStateFlow { _, _ -> true } + NonBooleanPropertyPrefixedWithIs:DefaultPortfolioSelectorController.kt$DefaultPortfolioSelectorController$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:PortfolioSelectorModel.kt$PortfolioSelectorModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + + 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..67d7387811 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 @@ -48,9 +46,10 @@ internal class ArchivedAccountListModel @Inject constructor( private val params = paramsContainer.require() private val onCloseClick = { router.pop() } - val uiState: StateFlow get() = _uiState - private val _uiState: MutableStateFlow = MutableStateFlow(getInitialState()) - private var getArchivedAccountsJob = JobHolder() + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + private val getArchivedAccountsJob = JobHolder() init { getArchivedAccounts() @@ -61,56 +60,48 @@ 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() }, + ) + }, + ) + + if (newState != null) { + uiState.value = newState } - newState?.let { _uiState.value = newState } } .flowOn(dispatchers.default) .launchIn(modelScope) .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 +113,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 } @@ -132,7 +137,7 @@ internal class ArchivedAccountListModel @Inject constructor( messageSender.showErrorDialog(universalError = featureError, onDismiss = router::pop) } - private fun logError(error: AccountFeatureError, params: Map = mapOf()) { + private fun logError(error: AccountFeatureError, params: Map = emptyMap()) { val exception = IllegalStateException(error.toString()) Timber.e(exception) 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..42c5faa452 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 @@ -1,9 +1,11 @@ package com.tangem.features.account.archived.entity +import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList +@Immutable internal sealed interface AccountArchivedUM { val onCloseClick: () -> Unit @@ -24,5 +26,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..782341c8e1 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, ), ) @@ -169,15 +170,15 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider ArchivedAccountUM( - accountId = it.toString(), + accountId = id.toString(), accountName = accountName, accountIconUM = portfolioIcon(), tokensInfo = stringReference("10 tokens"), networksInfo = stringReference("2 networks"), onClick = {}, - + isLoading = id % 2 == 0, ) }.toImmutableList() val first = AccountArchivedUM.Content( diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt index 85d4b0d97f..08191c3d3f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt @@ -124,10 +124,10 @@ internal class AccountCreateEditModel @Inject constructor( } private fun handleAddAccountError(error: AddCryptoPortfolioUseCase.Error) { - val duplicateAccountNames = (error as? AddCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet) + val isDuplicateAccountNamesError = (error as? AddCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet) ?.cause is AccountList.Error.DuplicateAccountNames when { - duplicateAccountNames -> showAccountNameExist() + isDuplicateAccountNamesError -> showAccountNameExist() else -> { showSomethingWrong() logError(error = AccountFeatureError.CreateAccount.FailedToCreateAccount(cause = error)) @@ -159,10 +159,10 @@ internal class AccountCreateEditModel @Inject constructor( } private fun handleEditAccountError(error: UpdateCryptoPortfolioUseCase.Error) { - val duplicateAccountNames = (error as? UpdateCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet) + val isDuplicateAccountNamesError = (error as? UpdateCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet) ?.cause is AccountList.Error.DuplicateAccountNames when { - duplicateAccountNames -> showAccountNameExist() + isDuplicateAccountNamesError -> showAccountNameExist() else -> { showSomethingWrong() logError(error = AccountFeatureError.EditAccount.FailedToEditAccount(cause = error)) @@ -176,43 +176,54 @@ internal class AccountCreateEditModel @Inject constructor( } private fun onCloseClick() { - val showConfirmDialog = uiState.value.buttonState.isButtonEnabled - if (showConfirmDialog) unsaveChangeDialog() else router.pop() + val shouldShowConfirmDialog = uiState.value.buttonState.isButtonEnabled + if (shouldShowConfirmDialog) unsaveChangeDialog() else router.pop() } private fun onIconSelect(icon: CryptoPortfolioIcon.Icon) { - uiState.value = uiState.value - .updateIconSelect(icon) - .validateNewState() + uiState.update { currentState -> + currentState.updateIconSelect(icon) + .validateNewState() + } } private fun onColorSelect(color: CryptoPortfolioIcon.Color) { - uiState.value = uiState.value - .updateColorSelect(color) - .validateNewState() + uiState.update { currentState -> + currentState.updateColorSelect(color) + .validateNewState() + } } private fun onNameChange(name: AccountNameUM) { - uiState.value = uiState.value - .updateName(name) - .validateNewState() + val isNotEmptyCustomName = (name as? AccountNameUM.Custom)?.raw?.isNotEmpty() == true + if (!name.isValidName() && isNotEmptyCustomName) { + Timber.d("Invalid account name: $name") + return + } + + uiState.update { currentState -> + currentState.updateName(name) + .validateNewState() + } } private fun AccountCreateEditUM.validateNewState(): AccountCreateEditUM { - val isValidName = this.account.name.toDomain().isRight() + val isValidName = this.account.name.isValidName() val isAvailableForConfirm = when (params) { is AccountCreateEditComponent.Params.Create -> isValidName is AccountCreateEditComponent.Params.Edit -> { 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) } } return this.updateButton(isButtonEnabled = isAvailableForConfirm) } + private fun AccountNameUM.isValidName(): Boolean = this.toDomain().isRight() + private fun getInitialState(): AccountCreateEditUM { return AccountCreateEditUM( title = umBuilder.toolbarTitle, @@ -250,7 +261,7 @@ internal class AccountCreateEditModel @Inject constructor( } } - private fun logError(error: AccountFeatureError, params: Map = mapOf()) { + private fun logError(error: AccountFeatureError, params: Map = emptyMap()) { val exception = IllegalStateException(error.toString()) Timber.e(exception) @@ -275,4 +286,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/DefaultAccountCreateEditComponent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/DefaultAccountCreateEditComponent.kt index e920c6cbbe..1c4f3b249d 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/DefaultAccountCreateEditComponent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/DefaultAccountCreateEditComponent.kt @@ -15,7 +15,7 @@ import dagger.assisted.AssistedInject internal class DefaultAccountCreateEditComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: AccountCreateEditComponent.Params, + @Assisted private val params: AccountCreateEditComponent.Params, ) : AppComponentContext by appComponentContext, AccountCreateEditComponent { private val model: AccountCreateEditModel = getOrCreateModel(params) @@ -24,8 +24,9 @@ internal class DefaultAccountCreateEditComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() AccountCreateEditContent( - modifier = modifier, state = state, + isCreateMode = params is AccountCreateEditComponent.Params.Create, + modifier = modifier, ) BackHandler(onBack = state.onCloseClick) } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt index c8b95db8c0..da8220a1b5 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt @@ -1,5 +1,6 @@ package com.tangem.features.account.createedit.entity +import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.extensions.TextReference @@ -23,6 +24,7 @@ internal data class AccountCreateEditUM( val onNameChange: (AccountNameUM) -> Unit, ) + @Immutable sealed interface DerivationInfo { val text: TextReference val index: Int? @@ -49,7 +51,7 @@ internal data class AccountCreateEditUM( data class Button( val isButtonEnabled: Boolean, - val showProgress: Boolean, + val shouldShowProgress: Boolean, val onConfirmClick: () -> Unit, val text: TextReference, ) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt index 3b710ed06d..a3e921cf2d 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt @@ -77,7 +77,7 @@ internal class AccountCreateEditUMBuilder( } return AccountCreateEditUM.Button( isButtonEnabled = false, - showProgress = false, + shouldShowProgress = false, onConfirmClick = onConfirmClick, text = text, ) @@ -119,7 +119,7 @@ internal class AccountCreateEditUMBuilder( } fun AccountCreateEditUM.toggleProgress(showProgress: Boolean): AccountCreateEditUM { - return this.copy(buttonState = this.buttonState.copy(showProgress = showProgress)) + return this.copy(buttonState = this.buttonState.copy(shouldShowProgress = showProgress)) } fun AccountCreateEditUM.updateDerivationIndex(derivationIndex: Int): AccountCreateEditUM { 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/createedit/ui/AccountCreateEditContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt index 2c7168bc93..89edce5203 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt @@ -1,22 +1,28 @@ package com.tangem.features.account.createedit.ui import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager @@ -43,8 +49,13 @@ import com.tangem.features.account.createedit.entity.AccountCreateEditUM import com.tangem.features.account.createedit.entity.AccountCreateEditUM.Account import kotlinx.collections.immutable.toImmutableList +@OptIn(ExperimentalFoundationApi::class) @Composable -internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modifier = Modifier) { +internal fun AccountCreateEditContent( + state: AccountCreateEditUM, + isCreateMode: Boolean, + modifier: Modifier = Modifier, +) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current Column( @@ -55,49 +66,55 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppBarWithBackButton( - text = state.title.resolveReference(), - onBackClick = state.onCloseClick, - iconRes = R.drawable.ic_close_24, - modifier = Modifier.height(TangemTheme.dimens.size56), - ) + AppBar(title = state.title, onBackClick = state.onCloseClick) Column( modifier = Modifier .padding(horizontal = 16.dp) .weight(1f), ) { - AccountSummary(state.account) + AccountSummary(state.account, isCreateMode) SpacerH24() AccountColor(state.colorsState) SpacerH24() AccountIcons(state.iconsState) SpacerH8() - Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = state.account.derivationInfo.text.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } - if (state.buttonState.showProgress) { - focusManager.clearFocus() - keyboardController?.hide() + DerivationInfo(text = state.account.derivationInfo.text) } + PrimaryButton( modifier = Modifier .fillMaxWidth() .padding(16.dp), enabled = state.buttonState.isButtonEnabled, - showProgress = state.buttonState.showProgress, + showProgress = state.buttonState.shouldShowProgress, text = state.buttonState.text.resolveReference(), onClick = state.buttonState.onConfirmClick, ) } + + SideEffect { + if (state.buttonState.shouldShowProgress) { + focusManager.clearFocus() + keyboardController?.hide() + } + } } @Composable -private fun AccountSummary(account: Account) { +private fun AppBar(title: TextReference, onBackClick: () -> Unit) { + AppBarWithBackButton( + text = title.resolveReference(), + onBackClick = onBackClick, + iconRes = R.drawable.ic_close_24, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) +} + +@Composable +private fun AccountSummary(account: Account, isCreateMode: Boolean) { + val focusRequester = remember { FocusRequester() } + Column( modifier = Modifier .clip(RoundedCornerShape(16.dp)) @@ -129,22 +146,29 @@ private fun AccountSummary(account: Account) { placeholder = account.inputPlaceholder, value = account.name.value.resolveReference(), singleLine = true, - onValueChange = { + onValueChange = { value -> /* * If the user had the default main account name and enters the same name during renaming, * we should use the default value instead of custom to avoid breaking the name validation process. */ - val newName = if (wasDefault && it == defaultAccountName) { + val newName = if (wasDefault && value == defaultAccountName) { AccountNameUM.DefaultMain } else { - AccountNameUM.Custom(raw = it) + AccountNameUM.Custom(raw = value) } account.onNameChange(newName) }, + textFieldModifier = Modifier.focusRequester(focusRequester), ) SpacerH(20.dp) } + + LaunchedEffect(Unit) { + if (isCreateMode) { + focusRequester.requestFocus() + } + } } @Suppress("LongMethod", "MagicNumber") @@ -163,7 +187,7 @@ private fun AccountColor(colorsState: AccountCreateEditUM.Colors) { contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - itemsIndexed(colorsState.list) { index, color -> + items(colorsState.list) { color -> val isSelected = color == colorsState.selected Box( contentAlignment = Alignment.Center, @@ -282,12 +306,22 @@ private fun AccountIcons(iconsState: AccountCreateEditUM.Icons) { } } +@Composable +private fun DerivationInfo(text: TextReference) { + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = text.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) +} + @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider::class) params: AccountCreateEditUM) { TangemThemePreview { - AccountCreateEditContent(state = params) + AccountCreateEditContent(state = params, isCreateMode = true) } } @@ -321,7 +355,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider() - val uiState: StateFlow get() = _uiState - private val _uiState: MutableStateFlow = MutableStateFlow(getInitialState()) + val uiState: StateFlow + field = MutableStateFlow(buildUI(params.account)) - private fun onEditAccountClick() { - router.push(AppRoute.EditAccount(params.account)) + private val accountId = params.account.accountId + + 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 +90,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 +103,58 @@ internal class AccountDetailsModel @Inject constructor( } } - private fun getInitialState(): AccountDetailsUM { - val account = params.account + private fun failedArchiveDialog(error: ArchiveCryptoPortfolioUseCase.Error) { + 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 + is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus, + -> R.string.account_could_not_archive_referral_program_title + } + 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 + is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus, + -> R.string.account_could_not_archive_referral_program_message + } + + 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) - .getOrNull()?.isMultiCurrency ?: false + val isMultiCurrency = getUserWalletUseCase(account.accountId.userWalletId).getOrNull() + ?.isMultiCurrency == true 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..15fe34025b 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 @@ -1,5 +1,6 @@ package com.tangem.features.account.details.entity +import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.extensions.TextReference @@ -13,10 +14,12 @@ internal data class AccountDetailsUM( val onManageTokensClick: () -> Unit, ) { + @Immutable sealed interface ArchiveMode { 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..1ff4d5400f 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_archiving), + 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,24 +187,27 @@ private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider:: } } +private val archiveModeAvailable + get() = AccountDetailsUM.ArchiveMode.Available( + onArchiveAccountClick = {}, + isLoading = false, + ) + private class PreviewStateProvider : CollectionPreviewParameterProvider( buildList { val accountName = "Main" - var portfolioIcon = AccountIconPreviewData.randomAccountIcon() + val portfolioIcon = AccountIconPreviewData.randomAccountIcon() val first = AccountDetailsUM( onCloseClick = {}, onAccountEditClick = {}, onManageTokensClick = {}, - archiveMode = AccountDetailsUM.ArchiveMode.Available( - onArchiveAccountClick = {}, - ), + archiveMode = archiveModeAvailable, accountName = stringReference(accountName), accountIcon = portfolioIcon, isManageTokensAvailable = true, ) add(first) - portfolioIcon = AccountIconPreviewData.randomAccountIcon(letter = true) - add(first.copy(accountIcon = portfolioIcon)) + add(first.copy(archiveMode = archiveModeAvailable.copy(isLoading = true))) add(first.copy(archiveMode = AccountDetailsUM.ArchiveMode.None)) add(first.copy(isManageTokensAvailable = false)) }, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt b/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt index 010ebe39f3..fcdd27ea9d 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt @@ -6,6 +6,7 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase 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.wallets.usecase.GetWalletsUseCase import com.tangem.features.account.PortfolioFetcher @@ -41,7 +42,10 @@ internal class DefaultPortfolioFetcher @AssistedInject constructor( get() = _mode init { - _mode.flatMapLatest(::combineUseCases) + _mode + // reset cache if mode(StateFlow) changed + .onEach { _data.resetReplayCache() } + .flatMapLatest(::combineUseCases) .flowOn(dispatchers.default) .onEach { _data.emit(it) } .launchIn(scope) @@ -73,13 +77,13 @@ internal class DefaultPortfolioFetcher @AssistedInject constructor( } } - private fun balancesForWallets(wallets: List): Flow> { + private fun balancesForWallets(wallets: List): Flow> { val balanceFlows = wallets.map { walletAccountsBalancesFlow(it) } return combine(balanceFlows) { pairs -> pairs.toMap() } } - private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow> = - accountStatusListFlow(wallet).map { wallet to PortfolioBalance(wallet, it) } + private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow> = + accountStatusListFlow(wallet).map { wallet.walletId to PortfolioBalance(wallet, it) } private fun accountStatusListFlow(wallet: UserWallet): Flow = singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(wallet.walletId)) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt index 1bd36673a5..00b2a52f71 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt @@ -44,7 +44,7 @@ internal class DefaultPortfolioSelectorController @Inject constructor( data.balances.forEach { wallet, balance -> val accountStatuses = balance.accountsBalance.accountStatuses .find { accountId == it.account.accountId } - if (accountStatuses != null) result = wallet to accountStatuses + if (accountStatuses != null) result = balance.userWallet to accountStatuses } return@combine result diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt index 0472196bdb..570998a30d 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -42,13 +43,23 @@ internal class PortfolioSelectorModel @Inject constructor( field = MutableStateFlow(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 = isAccountsMode, + portfolioData = portfolioData, + artworks = artworks, + isEnabled = isEnabled, + selectedAccount = selectedAccount, + ) val title = when (isAccountsMode) { true -> resourceReference(R.string.common_choose_account) false -> resourceReference(R.string.common_choose_wallet) @@ -68,21 +79,34 @@ 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 = portfolioData, + artworks = artworks, + isEnabled = isEnabled, + selectedAccount = selectedAccount, + ) + false -> buildWalletList( + portfolioData = portfolioData, + artworks = artworks, + isEnabled = isEnabled, + selectedAccount = 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 val lockedWallets = mutableListOf() - portfolioData.balances.forEach { wallet, portfolio -> + portfolioData.balances.forEach { walletId, portfolio -> val balance = portfolio.walletBalance + val wallet = portfolio.userWallet val walletItemUM = UserWalletItemUMConverter( onClick = { selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId) @@ -94,12 +118,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,12 +142,14 @@ 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 val lockedWallets = mutableListOf() - portfolioData.balances.forEach { wallet, portfolio -> + portfolioData.balances.forEach { walletId, portfolio -> val balance = portfolio.walletBalance + val wallet = portfolio.userWallet val walletItemUM = UserWalletItemUMConverter( onClick = { selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId) @@ -133,15 +161,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 +190,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 +204,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..83d3979381 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,7 +21,8 @@ sealed interface PortfolioSelectorItemUM { data class Portfolio( val item: UserWalletItemUM, + val isSelected: Boolean, ) : PortfolioSelectorItemUM { - override val id: String = item.id.stringValue + override val id: String = item.id } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt index f33617879f..5e63cd75cc 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt @@ -1,7 +1,6 @@ package com.tangem.features.account.selector.ui import android.content.res.Configuration -import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable 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..6b8f1872fd 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 @@ -29,7 +32,6 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.account.impl.R import com.tangem.features.account.selector.entity.PortfolioSelectorItemUM import com.tangem.features.account.selector.entity.PortfolioSelectorUM @@ -68,14 +70,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) }, @@ -122,7 +131,7 @@ internal object PortfolioSelectorPreviewData { private val accountItem: UserWalletItemUM get() = UserWalletItemUM( - id = UserWalletId(UUID.randomUUID().toString().encodeToByteArray()), + id = UUID.randomUUID().toString(), name = accountName, information = UserWalletItemUM.Information.Loaded(stringReference("12 tokens")), balance = UserWalletItemUM.Balance.Loaded("$726.04", false), @@ -139,7 +148,7 @@ internal object PortfolioSelectorPreviewData { private val walletItem: UserWalletItemUM get() = UserWalletItemUM( - id = UserWalletId(UUID.randomUUID().toString().encodeToByteArray()), + id = UUID.randomUUID().toString(), name = walletName, information = UserWalletItemUM.Information.Loaded(stringReference("12 tokens")), balance = UserWalletItemUM.Balance.Loaded("$726.04", false), @@ -152,76 +161,65 @@ internal object PortfolioSelectorPreviewData { get() = walletItem.copy(isEnabled = false) val firstList - get() = buildList { + get() = listOf( PortfolioSelectorItemUM.GroupTitle( id = UUID.randomUUID().toString(), name = stringReference("Tangem 2.0"), - ).let(::add) - accountItem - .let { PortfolioSelectorItemUM.Portfolio(it) } - .let(::add) - lockedAccountItem - .let { PortfolioSelectorItemUM.Portfolio(it) } - .let(::add) + ), + PortfolioSelectorItemUM.Portfolio(accountItem, false), + PortfolioSelectorItemUM.Portfolio(lockedAccountItem, false), PortfolioSelectorItemUM.GroupTitle( id = UUID.randomUUID().toString(), name = stringReference("Tangem White"), - ).let(::add) - accountItem.let { PortfolioSelectorItemUM.Portfolio(it) } - .let(::add) - } + ), + PortfolioSelectorItemUM.Portfolio(accountItem, true), + ) val secondList - get() = firstList + buildList { + get() = firstList + listOf( PortfolioSelectorItemUM.GroupTitle( id = UUID.randomUUID().toString(), name = resourceReference(R.string.common_locked_wallets), - ).let(::add) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) - } + ), + PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false), + PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false), + ) val walletList - get() = buildList { - add(PortfolioSelectorItemUM.Portfolio(walletItem)) - add(PortfolioSelectorItemUM.Portfolio(walletItem)) - } + get() = listOf( + PortfolioSelectorItemUM.Portfolio(walletItem, false), + PortfolioSelectorItemUM.Portfolio(walletItem, true), + ) val lockedWalletList - get() = buildList { - add(PortfolioSelectorItemUM.Portfolio(walletItem)) - val title = PortfolioSelectorItemUM.GroupTitle( + get() = listOf( + PortfolioSelectorItemUM.Portfolio(walletItem, true), + PortfolioSelectorItemUM.GroupTitle( id = UUID.randomUUID().toString(), name = resourceReference(R.string.common_locked_wallets), - ) - add(title) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) - } + ), + PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false), + PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false), + ) } internal class PortfolioSelectorPreviewStateProvider : CollectionPreviewParameterProvider( - buildList { - val first = PortfolioSelectorUM( + listOf( + PortfolioSelectorUM( title = resourceReference(R.string.common_choose_account), items = firstList.toImmutableList(), - ) - val second = PortfolioSelectorUM( + ), + PortfolioSelectorUM( title = resourceReference(R.string.common_choose_account), items = secondList.toImmutableList(), - ) - val walletListUM = PortfolioSelectorUM( + ), + PortfolioSelectorUM( title = resourceReference(R.string.common_choose_wallet), items = walletList.toImmutableList(), - ) - val lockedWalletListUM = - PortfolioSelectorUM( - title = resourceReference(R.string.common_choose_wallet), - items = lockedWalletList.toImmutableList(), - ) - add(first) - add(second) - add(walletListUM) - add(lockedWalletListUM) - }, + ), + PortfolioSelectorUM( + title = resourceReference(R.string.common_choose_wallet), + items = lockedWalletList.toImmutableList(), + ), + ), ) \ No newline at end of file diff --git a/features/biometry/impl/detekt-baseline-debug.xml b/features/biometry/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..f439e02f2e --- /dev/null +++ b/features/biometry/impl/detekt-baseline-debug.xml @@ -0,0 +1,10 @@ + + + + + BooleanPropertyNaming:AskBiometryUM.kt$AskBiometryUM$val bottomSheetVariant: Boolean = false + BooleanPropertyNaming:AskBiometryUM.kt$AskBiometryUM$val showProgress: Boolean = false + BooleanPropertyNaming:DefaultAskBiometryComponent.kt$DefaultAskBiometryComponent$val bsShown by bsShown.collectAsStateWithLifecycle() + MultilineLambdaItParameter:AskBiometryModel.kt$AskBiometryModel${ uiMessageSender.send( SnackbarMessage(stringReference("Something went wrong. Please contact support: $it")), ) } + + 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..b6f43fdbbe 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 @@ -114,6 +114,7 @@ internal class AskBiometryModel @Inject constructor( if (hotWalletFeatureToggles.isHotWalletEnabled) { walletsRepository.setUseBiometricAuthentication(value = true) + walletsRepository.setRequireAccessCode(value = false) setBiometryLockForAllWallets() cardSdkConfigRepository.setAccessCodeRequestPolicy( isBiometricsRequestPolicy = walletsRepository.requireAccessCode().not(), @@ -135,13 +136,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..bc586d7d95 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 @@ -75,17 +75,20 @@ internal class CreateWalletSelectionModel @Inject constructor( ), ), onBuyClick = ::onBuyClick, + shouldShowAlreadyHaveWallet = true, ), ) init { - showAlreadyHaveWalletWithDelay() + // temporarily disabled by timer and enabled by default + // showAlreadyHaveWalletWithDelay() } + @Suppress("UnusedPrivateMember") private fun showAlreadyHaveWalletWithDelay() { modelScope.launch { delay(SHOW_ALREADY_HAVE_WALLET_DELAY) - uiState.update { it.copy(showAlreadyHaveWallet = true) } + uiState.update { it.copy(shouldShowAlreadyHaveWallet = true) } } } @@ -94,7 +97,7 @@ internal class CreateWalletSelectionModel @Inject constructor( } private fun onHardwareWalletClick() { - // TODO [REDACTED_TASK_KEY] + router.push(AppRoute.CreateHardwareWallet) } private fun onBuyClick() { diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt index 1d76d78bac..98cbe9a90b 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt @@ -6,7 +6,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class CreateWalletSelectionUM( val isScanInProgress: Boolean = false, - val showAlreadyHaveWallet: Boolean = false, + val shouldShowAlreadyHaveWallet: Boolean = false, val blocks: ImmutableList, val onBackClick: () -> Unit, val onBuyClick: () -> Unit, diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt index 1a953f008d..7fde4ee3ac 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -104,7 +104,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi ) } } - AnimatedVisibility(state.showAlreadyHaveWallet) { + AnimatedVisibility(state.shouldShowAlreadyHaveWallet) { AlreadyHaveTangemWalletBlock( onBuyClick = state.onBuyClick, isScanInProgress = state.isScanInProgress, @@ -161,9 +161,9 @@ private fun WalletBlock( thickness = 0.5.dp, color = TangemTheme.colors.stroke.primary, ) - features.forEach { + features.forEach { feature -> Feature( - feature = it, + feature = feature, modifier = Modifier .padding(top = 12.dp), ) diff --git a/features/create-wallet-start/impl/detekt-baseline-debug.xml b/features/create-wallet-start/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..7090dd6ebf --- /dev/null +++ b/features/create-wallet-start/impl/detekt-baseline-debug.xml @@ -0,0 +1,9 @@ + + + + + BooleanPropertyNaming:CreateWalletStartUM.kt$CreateWalletStartUM$val showScanSecondaryButton: Boolean + MultilineLambdaItParameter:CreateWalletStartContent.kt${ FeatureItem( iconResId = it.iconResId, text = it.text, ) } + MultilineLambdaItParameter:CreateWalletStartModel.kt$CreateWalletStartModel${ 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 { appRouter.replaceAll(AppRoute.Wallet) } } } } + + diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt index 9c6e3fedf3..64070ed61e 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt @@ -296,7 +296,7 @@ private fun AdaptiveScrollableContent( Layout( modifier = Modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState(initial = Int.MAX_VALUE)), content = { Column( modifier = Modifier.fillMaxWidth(), diff --git a/features/details/impl/detekt-baseline-debug.xml b/features/details/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..e574008e71 --- /dev/null +++ b/features/details/impl/detekt-baseline-debug.xml @@ -0,0 +1,16 @@ + + + + + HasPlatformType:DetailsModel.kt$DetailsModel.Companion$val APP_LANGUAGE = Locale.getDefault().language + HasPlatformType:DetailsModel.kt$DetailsModel.Companion$val SYSTEM_LANGUAGE = runCatching { Resources.getSystem().configuration.locales[0].language }.getOrElse { "" } + MultilineLambdaItParameter:DetailsModel.kt$DetailsModel${ Timber.w("Unable to check WalletConnect availability: $it") false } + MultilineLambdaItParameter:DetailsModel.kt$DetailsModel${ it.copy( selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig( isShown = true, onDismissRequest = { state.update { it.copy( selectFeedbackEmailTypeBSConfig = it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), ) } }, content = SelectEmailFeedbackTypeBS( onOptionClick = { option -> onEmailFeedbackTypeOptionSelected( selectedWalletMetaInfo = selectedWalletMetaInfo, option = option, ) state.update { it.copy( selectFeedbackEmailTypeBSConfig = it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), ) } }, ), ), ) } + MultilineLambdaItParameter:DetailsModel.kt$DetailsModel${ it.copy( selectFeedbackEmailTypeBSConfig = it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), ) } + MultilineLambdaItParameter:PreviewUserWalletListComponent.kt$PreviewUserWalletListComponent${ it.copy( balance = UserWalletItemUM.Balance.Loaded( value = "1.000 BTC", isFlickering = true, ), ) } + MultilineLambdaItParameter:UserWalletSaver.kt$UserWalletSaver${ val message = it.message if (!message.isNullOrEmpty()) { messageSender.send(SnackbarMessage(message)) } } + NonBooleanPropertyPrefixedWithIs:UserWalletListModel.kt$UserWalletListModel$private val isWalletSavingInProgress: MutableStateFlow<Boolean> = MutableStateFlow(value = false) + RedundantSuspendModifier:UserWalletSaver.kt$UserWalletSaver$suspend + UnnecessaryLet:ItemsBuilder.kt$ItemsBuilder$let(::add) + + diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt index 3e15f2bc56..86ecb2a42f 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt @@ -10,7 +10,6 @@ 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.domain.models.wallet.UserWalletId import com.tangem.features.details.component.UserWalletListComponent import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.impl.R @@ -25,7 +24,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { UserWalletListUM( userWallets = persistentListOf( UserWalletItemUM( - id = UserWalletId("user_wallet_1".encodeToByteArray()), + id = "user_wallet_1", name = stringReference("My Wallet"), information = getInformation(cardCount = 1), balance = UserWalletItemUM.Balance.Loading, @@ -33,7 +32,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { onClick = {}, ), UserWalletItemUM( - id = UserWalletId("user_wallet_2".encodeToByteArray()), + id = "user_wallet_2", name = stringReference("Old wallet"), information = getInformation(cardCount = 2), balance = UserWalletItemUM.Balance.Loading, @@ -41,7 +40,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { onClick = {}, ), UserWalletItemUM( - id = UserWalletId("user_wallet_3".encodeToByteArray()), + id = "user_wallet_3", name = stringReference("Multi Card"), information = getInformation(cardCount = 3), balance = UserWalletItemUM.Balance.Loading, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index a096f5231d..923627f080 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.details.model import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.userwallet.handle import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -8,7 +9,9 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase +import com.tangem.domain.wallets.usecase.UnlockWalletUseCase import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.impl.R import com.tangem.features.details.utils.UserWalletSaver @@ -21,6 +24,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @Suppress("LongParameterList") @@ -33,6 +38,7 @@ internal class UserWalletListModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val userWalletSaver: UserWalletSaver, private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val unlockWalletUseCase: UnlockWalletUseCase, ) : Model() { private val isWalletSavingInProgress: MutableStateFlow = MutableStateFlow(value = false) @@ -40,7 +46,8 @@ internal class UserWalletListModel @Inject constructor( messageSender = messageSender, onlyMultiCurrency = false, isAuthMode = false, - onWalletClick = { userWalletId -> router.push(AppRoute.WalletSettings(userWalletId)) }, + isClickableIfLocked = hotWalletFeatureToggles.isHotWalletEnabled, + onWalletClick = ::onWalletClicked, ) val state: MutableStateFlow = MutableStateFlow( @@ -87,4 +94,23 @@ internal class UserWalletListModel @Inject constructor( } } } + + private fun onWalletClicked(userWalletId: UserWalletId) { + if (hotWalletFeatureToggles.isHotWalletEnabled) { + modelScope.launch { + unlockWalletUseCase(userWalletId) + .onRight { router.push(AppRoute.WalletSettings(userWalletId)) } + .onLeft { error -> + Timber.e("Failed to unlock wallet $userWalletId: $error") + error.handle( + onUserCancelled = {}, + onAlreadyUnlocked = { router.push(AppRoute.WalletSettings(userWalletId)) }, + showMessage = messageSender::send, + ) + } + } + } else { + router.push(AppRoute.WalletSettings(userWalletId)) + } + } } \ No newline at end of file diff --git a/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerWebViewClient.kt b/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerWebViewClient.kt index c5fbbfb32b..000dbb6aeb 100644 --- a/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerWebViewClient.kt +++ b/features/disclaimer/impl/src/main/java/com/tangem/features/disclaimer/impl/ui/DisclaimerWebViewClient.kt @@ -14,6 +14,7 @@ import timber.log.Timber * [REDACTED_AUTHOR] */ +@Suppress("NullableToStringCall") internal class DisclaimerWebViewClient( private val onLoadingFinished: () -> Unit, ) : AccompanistWebViewClient() { diff --git a/features/feed/api/.gitignore b/features/feed/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/feed/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/feed/api/build.gradle.kts b/features/feed/api/build.gradle.kts new file mode 100644 index 0000000000..73aa93625c --- /dev/null +++ b/features/feed/api/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("kotlin-parcelize") + id("configuration") +} + +android { + namespace = "com.tangem.features.feed.api" +} + +dependencies { + implementation(deps.compose.foundation) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Project - Domain */ + implementation(projects.domain.core) + implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.markets.models) +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/BottomSheetState.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/BottomSheetState.kt new file mode 100644 index 0000000000..9b58eb7690 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/BottomSheetState.kt @@ -0,0 +1,6 @@ +package com.tangem.features.feed.entry + +enum class BottomSheetState { + EXPANDED, + COLLAPSED, +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt new file mode 100644 index 0000000000..ad7aab9506 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.feed.entry.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.features.feed.entry.BottomSheetState + +@Stable +interface FeedEntryComponent { + + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory { + fun create(context: AppComponentContext): FeedEntryComponent + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt new file mode 100644 index 0000000000..c04c556450 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt @@ -0,0 +1,5 @@ +package com.tangem.features.feed.entry.featuretoggle + +interface FeedFeatureToggle { + val isFeedEnabled: Boolean +} \ No newline at end of file diff --git a/features/feed/impl/.gitignore b/features/feed/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/feed/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts new file mode 100644 index 0000000000..c309d45ccf --- /dev/null +++ b/features/feed/impl/build.gradle.kts @@ -0,0 +1,102 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.feed.impl" + + packaging { + resources { + merges += "paymentrequest.proto" + } + } +} + +dependencies { + /* Project - API */ + api(projects.features.feed.api) + api(projects.features.onramp.api) + api(projects.features.sendV2.api) + api(projects.features.tokenRecieve.api) + api(projects.features.wallet.api) + api(projects.features.account.api) + + /* Data */ + implementation(projects.data.common) + + /* Domain */ + implementation(projects.domain.account) + implementation(projects.domain.account.status) + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.card) + implementation(projects.domain.demo) + implementation(projects.domain.feedback) + implementation(projects.domain.feedback.models) + implementation(projects.domain.manageTokens) + implementation(projects.domain.markets) + implementation(projects.domain.onramp.models) + implementation(projects.domain.staking.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + implementation(projects.domain.settings) + implementation(projects.domain.notifications.models) + implementation(projects.domain.transaction) + + // FIXME [REDACTED_TASK_KEY] + // Remove the "Buy" and "Sell" actions from the redux middleware. + // Instead, create some kind of interface for such cases. + /* Redux -_- */ + implementation(projects.domain.legacy) + implementation(deps.reKotlin) + + /* Compose */ + implementation(deps.compose.coil) + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.ui.utils) + implementation(deps.lifecycle.compose) + implementation(deps.androidx.activity.compose) + implementation(deps.markdown.composeview) + + /* DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /* Other */ + implementation(deps.kotlin.immutable.collections) + implementation(deps.timber) + implementation(deps.decompose.ext.compose) + + /* Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.configToggles) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.navigation) + + /* Common */ + implementation(projects.common.ui) + implementation(projects.common.uiCharts) + implementation(projects.common.routing) + + /* Libs */ + implementation(projects.libs.crypto) + implementation(projects.libs.blockchainSdk) + + /** Tangem libraries */ + implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt new file mode 100644 index 0000000000..cdf3a46598 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -0,0 +1,91 @@ +package com.tangem.features.feed.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.popWhile +import com.arkivanov.decompose.value.Value +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.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.entry.BottomSheetState +import com.tangem.features.feed.entry.components.FeedEntryComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultFeedEntryComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + private val feedEntryChildFactory: FeedEntryChildFactory, +) : FeedEntryComponent, AppComponentContext by context { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val stack: Value> = childStack( + key = "main", + source = stackNavigation, + serializer = FeedEntryChildFactory.Child.serializer(), + initialConfiguration = FeedEntryChildFactory.Child.Feed, + handleBackButton = false, + childFactory = { configuration, factoryContext -> + feedEntryChildFactory.createChild( + child = configuration, + appComponentContext = childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + onTokenClick = ::marketsListTokenSelected, + ) + }, + ) + + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + bottomSheetState // TODO will be continued in next tasks. + } + + private fun marketsListTokenSelected(token: TokenMarketParams, appCurrency: AppCurrency) { + innerRouter.push( + route = FeedEntryChildFactory.Child.TokenDetails( + params = DefaultMarketsTokenDetailsComponent.Params( + token = token, + appCurrency = appCurrency, + shouldShowPortfolio = true, + analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams( + blockchain = null, + source = "Market", + ), + ), + ), + ) + } + + private fun onChildBack() { + if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) { + stackNavigation.popWhile { it != FeedEntryChildFactory.Child.Feed } + } + } + + @AssistedFactory + interface Factory : FeedEntryComponent.Factory { + override fun create(context: AppComponentContext): DefaultFeedEntryComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt new file mode 100644 index 0000000000..c33781008b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -0,0 +1,77 @@ +package com.tangem.features.feed.components + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.feed.DefaultFeedComponent +import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent +import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent +import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import kotlinx.serialization.Serializable + +internal class FeedEntryChildFactory { + + @Serializable + @Immutable + sealed interface Child : Route { + + @Serializable + @Immutable + data object Feed : Child + + @Serializable + @Immutable + data object TokenList : Child + + @Serializable + @Immutable + data class TokenDetails(val params: DefaultMarketsTokenDetailsComponent.Params) : Child + + @Serializable + @Immutable + data object NewsList : Child + + @Serializable + @Immutable + data object NewsDetails : Child + } + + fun createChild( + child: Child, + appComponentContext: AppComponentContext, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): Any { + return when (child) { + is Child.TokenDetails -> { + DefaultMarketsTokenDetailsComponent( + appComponentContext = appComponentContext, + params = child.params, + ) + } + is Child.TokenList -> { + DefaultMarketsTokenListComponent( + appComponentContext = appComponentContext, + onTokenClick = onTokenClick, + ) + } + Child.NewsDetails -> { + DefaultNewsDetailsComponent( + appComponentContext = appComponentContext, + ) + } + Child.NewsList -> { + DefaultNewsListComponent( + appComponentContext = appComponentContext, + ) + } + Child.Feed -> { + DefaultFeedComponent( + appComponentContext = appComponentContext, + ) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt new file mode 100644 index 0000000000..a6bd119f49 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.feed.components.feed + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableModularContentComponent + +internal class DefaultFeedComponent( + appComponentContext: AppComponentContext, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + @Composable + override fun Title() { + } + + @Composable + override fun Content(modifier: Modifier) { + } + + @Composable + override fun Footer() { + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt new file mode 100644 index 0000000000..f6933c66cd --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -0,0 +1,41 @@ +package com.tangem.features.feed.components.market.details + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import kotlinx.serialization.Serializable + +internal class DefaultMarketsTokenDetailsComponent( + appComponentContext: AppComponentContext, + val params: Params, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + @Composable + override fun Title() { + } + + @Composable + override fun Content(modifier: Modifier) { + } + + @Composable + override fun Footer() { + } + + @Serializable + data class Params( + val token: TokenMarketParams, + val appCurrency: AppCurrency, + val shouldShowPortfolio: Boolean, + val analyticsParams: AnalyticsParams?, + ) + + @Serializable + data class AnalyticsParams( + val blockchain: String?, + val source: String, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt new file mode 100644 index 0000000000..ca5d351c48 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt @@ -0,0 +1,27 @@ +package com.tangem.features.feed.components.market.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams + +@Suppress("UnusedPrivateProperty") // TODO will be remove in next PR +internal class DefaultMarketsTokenListComponent( + appComponentContext: AppComponentContext, + private val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)? = null, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + @Composable + override fun Title() { + } + + @Composable + override fun Content(modifier: Modifier) { + } + + @Composable + override fun Footer() { + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt new file mode 100644 index 0000000000..bad826640c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.feed.components.news.details + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableModularContentComponent + +internal class DefaultNewsDetailsComponent( + appComponentContext: AppComponentContext, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + @Composable + override fun Title() { + } + + @Composable + override fun Content(modifier: Modifier) { + } + + @Composable + override fun Footer() { + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt new file mode 100644 index 0000000000..bdde19a5c8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.feed.components.news.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableModularContentComponent + +internal class DefaultNewsListComponent( + appComponentContext: AppComponentContext, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + @Composable + override fun Title() { + } + + @Composable + override fun Content(modifier: Modifier) { + } + + @Composable + override fun Footer() { + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt new file mode 100644 index 0000000000..7e9bee8655 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt @@ -0,0 +1,23 @@ +package com.tangem.features.feed.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle +import com.tangem.features.feed.featuretoggle.DefaultFeedFeatureToggle +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 FeedFeatureToggleModule { + + @Provides + @Singleton + fun provideFeedFeatureToggle(featureTogglesManager: FeatureTogglesManager): FeedFeatureToggle { + return DefaultFeedFeatureToggle( + featureTogglesManager = featureTogglesManager, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt new file mode 100644 index 0000000000..c15096b261 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt @@ -0,0 +1,12 @@ +package com.tangem.features.feed.featuretoggle + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle + +internal class DefaultFeedFeatureToggle( + private val featureTogglesManager: FeatureTogglesManager, +) : FeedFeatureToggle { + + override val isFeedEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("FEED_ENABLED") +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt new file mode 100644 index 0000000000..51c97516b7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -0,0 +1,388 @@ +package com.tangem.features.feed.ui.feed + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.fields.SearchBar +import com.tangem.common.ui.news.ArticleCard +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState +import com.tangem.features.feed.ui.feed.state.FeedListCallbacks +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.MarketChartConfig +import com.tangem.features.feed.ui.feed.state.MarketChartUM +import com.tangem.features.feed.ui.market.components.MarketsListItem +import com.tangem.features.feed.ui.market.components.MarketsListItemPlaceholder +import com.tangem.features.feed.ui.market.state.MarketsListItemUM +import com.tangem.features.feed.ui.market.state.SortByTypeUM +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun FeedList(state: FeedListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) { + val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .drawBehind { drawRect(background) }, + ) { + SearchBar( + modifier = Modifier + .drawBehind { drawRect(background) } + .padding( + start = 16.dp, + end = 16.dp, + bottom = 8.dp, + ) + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + onHeaderSizeChange(coordinates.size.height.toDp()) + } + } + } + .padding(bottom = 4.dp), + state = state.searchBar, + ) + + SpacerH(20.dp) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResourceSafe(R.string.feed_market_and_news), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = state.currentDate, + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerH(32.dp) + + MarketBlock( + marketChartConfig = state.marketChartConfig, + feedListCallbacks = state.feedListCallbacks, + ) + + NewsBlock( + news = state.news, + feedListCallbacks = state.feedListCallbacks, + trendingArticle = state.trendingArticle, + ) + + MarketPulseBlock( + marketChartConfig = state.marketChartConfig, + feedListCallbacks = state.feedListCallbacks, + ) + } +} + +@Composable +private fun MarketBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) { + if (marketChartConfig.marketCharts.isNotEmpty()) { + Header( + title = { + Text( + text = stringResourceSafe(R.string.markets_common_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + }, + onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) }, + ) + + SpacerH(12.dp) + + marketChartConfig.marketCharts[SortByTypeUM.Rating]?.let { chart -> + Charts( + onItemClick = feedListCallbacks.onMarketItemClick, + modifier = Modifier.padding(horizontal = 16.dp), + marketChart = chart, + ) + } + SpacerH(32.dp) + } +} + +@Composable +private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) { + if (marketChartConfig.marketCharts.isNotEmpty()) { + LazyRow( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + state = rememberLazyListState(), + ) { + items( + items = marketChartConfig.getFilterPreset(), + key = SortByTypeUM::name, + ) { sortByTypeUM -> + FilterChip( + sortByTypeUM = sortByTypeUM, + isSelected = sortByTypeUM == marketChartConfig.currentSortByType, + onClick = { feedListCallbacks.onSortTypeClick(sortByTypeUM) }, + ) + } + } + + Header( + title = { + Text( + text = stringResourceSafe(R.string.markets_common_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + }, + onSeeAllClick = { feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType) }, + ) + + SpacerH(12.dp) + + AnimatedContent( + targetState = marketChartConfig.currentSortByType, + label = "MarketPulseChartAnimation", + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { currentSortType -> + marketChartConfig.marketCharts[currentSortType]?.let { chart -> + Charts( + onItemClick = feedListCallbacks.onMarketItemClick, + modifier = Modifier.padding(horizontal = 16.dp), + marketChart = chart, + ) + } + } + SpacerH(32.dp) + } +} + +@Suppress("CanBeNonNullable") +@Composable +private fun NewsBlock( + feedListCallbacks: FeedListCallbacks, + news: ImmutableList, + trendingArticle: ArticleConfigUM?, +) { + if (news.isNotEmpty()) { + Header( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResourceSafe(R.string.common_news), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + + SpacerW(4.dp) + + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20), + contentDescription = null, + ) + + SpacerW(2.dp) + + Text( + text = buildAnnotatedString { + withStyle( + SpanStyle().copy( + brush = Brush.linearGradient( + GRADIENT_START to LinearGradientFirstPart, + GRADIENT_END to LinearGradientSecondPart, + ), + ), + ) { + append(stringResourceSafe(R.string.feed_tangem_ai)) + } + }, + style = TangemTheme.typography.subtitle1, + ) + } + }, + onSeeAllClick = feedListCallbacks.onOpenAllNews, + ) + + SpacerH(12.dp) + + trendingArticle?.let { article -> + ArticleCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + articleConfigUM = article, + onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, + ) + + SpacerH(12.dp) + } + + LazyRow( + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + state = rememberLazyListState(), + ) { + items( + items = news, + key = ArticleConfigUM::id, + ) { article -> + ArticleCard( + articleConfigUM = article, + onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, + modifier = Modifier.size(164.dp), + ) + } + } + } +} + +@Composable +private fun Header(title: @Composable () -> Unit, onSeeAllClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + title() + + SecondarySmallButton( + config = SmallButtonConfig( + text = TextReference.Res(R.string.common_see_all), + onClick = onSeeAllClick, + ), + ) + } +} + +@Composable +private fun Charts( + marketChart: MarketChartUM, + onItemClick: (MarketsListItemUM) -> Unit, + modifier: Modifier = Modifier, +) { + BlockCard(modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + when (marketChart) { + MarketChartUM.Loading -> { + repeat(DEFAULT_CHART_SIZE_IN_MARKET) { + MarketsListItemPlaceholder() + } + } + is MarketChartUM.LoadingError -> { + // TODO will be created in [REDACTED_TASK_KEY] + } + is MarketChartUM.Content -> { + marketChart.items.fastForEach { chart -> + MarketsListItem( + model = chart, + onClick = { onItemClick(chart) }, + ) + } + } + } + } + } +} + +@Composable +private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(12.dp)) + .background( + color = if (isSelected) { + TangemTheme.colors.button.primary + } else { + TangemTheme.colors.button.secondary + }, + ) + .clickable( + onClick = onClick, + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + ) + .padding(vertical = 8.dp, horizontal = 24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = sortByTypeUM.text.resolveReference(), + style = TangemTheme.typography.button, + color = if (isSelected) { + TangemTheme.colors.text.primary2 + } else { + TangemTheme.colors.text.primary1 + }, + ) + } +} + +private const val DEFAULT_CHART_SIZE_IN_MARKET = 5 +private const val GRADIENT_START = 0f +private const val GRADIENT_END = 0.5f +private val LinearGradientFirstPart = Color(0xFF635EEC) +private val LinearGradientSecondPart = Color(0xFFE05AED) + +@Preview(showBackground = true) +@Composable +private fun FeedListPreview() { + TangemThemePreview { + FeedList( + state = createFeedPreviewState(), + onHeaderSizeChange = {}, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt new file mode 100644 index 0000000000..734644fa3d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -0,0 +1,256 @@ +package com.tangem.features.feed.ui.feed.preview + +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.common.ui.news.ArticleTagUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.ui.feed.state.* +import com.tangem.features.feed.ui.market.state.MarketsListItemUM +import com.tangem.features.feed.ui.market.state.SortByTypeUM +import kotlinx.collections.immutable.* + +@Suppress("MagicNumber") +internal object FeedListPreviewDataProvider { + + fun createFeedPreviewState(): FeedListUM { + val articles = createSampleArticles() + val marketItems = createSampleMarketItems() + return FeedListUM( + currentDate = "20 November", + searchBar = SearchBarUM( + placeholderText = TextReference.Str("Search tokens & news"), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + ), + feedListCallbacks = FeedListCallbacks( + onSearchClick = {}, + onMarketOpenClick = {}, + onArticleClick = {}, + onOpenAllNews = {}, + onMarketItemClick = {}, + onSortTypeClick = {}, + ), + news = articles.filter { it.isTrending.not() }.toImmutableList(), + trendingArticle = articles.first { it.isTrending }, + marketChartConfig = MarketChartConfig( + marketCharts = createMarketCharts(marketItems, includeErrorState = false), + currentSortByType = SortByTypeUM.TopGainers, + ), + ) + } + + private fun createMarketCharts( + items: ImmutableList, + includeErrorState: Boolean, + ): ImmutableMap { + val baseCharts = mapOf( + SortByTypeUM.TopGainers to createChartContent( + sortByType = SortByTypeUM.TopGainers, + items = items, + isSelected = true, + ), + SortByTypeUM.Trending to createChartContent( + sortByType = SortByTypeUM.Trending, + items = items, + isSelected = false, + ), + SortByTypeUM.ExperiencedBuyers to createChartContent( + sortByType = SortByTypeUM.ExperiencedBuyers, + items = items, + isSelected = false, + ), + SortByTypeUM.Staking to MarketChartUM.Loading, + SortByTypeUM.TopLosers to MarketChartUM.Loading, + ) + + val ratingChart = if (includeErrorState) { + MarketChartUM.LoadingError(onRetryClicked = {}) + } else { + createChartContent( + sortByType = SortByTypeUM.Rating, + items = items, + isSelected = false, + ) + } + + return persistentMapOf( + SortByTypeUM.Rating to ratingChart, + *baseCharts.entries.map { it.toPair() }.toTypedArray(), + ) + } + + private fun createChartContent( + sortByType: SortByTypeUM, + items: ImmutableList, + isSelected: Boolean, + ): MarketChartUM.Content { + return MarketChartUM.Content( + items = items, + triggerScrollReset = consumedEvent(), + sortChartConfig = SortChartConfigUM( + sortByType = sortByType, + isSelected = isSelected, + ), + ) + } + + private fun createSampleArticles(): ImmutableList = persistentListOf( + ArticleConfigUM( + id = 1, + title = "Bitcoin ETF reaches new highs, institutions pile in", + score = 0.82f, + createdAt = "2h ago", + isTrending = true, + tags = createArticleTags(), + isViewed = false, + ), + ArticleConfigUM( + id = 2, + title = "Layer 2 networks battle for dominance amid fee wars", + score = 0.71f, + createdAt = "4h ago", + isTrending = false, + tags = createArticleTags(), + isViewed = true, + ), + ArticleConfigUM( + id = 3, + title = "Stablecoins expand on-ramps across LATAM", + score = 0.65f, + createdAt = "Yesterday", + isTrending = false, + tags = createArticleTags(), + isViewed = false, + ), + ArticleConfigUM( + id = 4, + title = "Stablecoins expand on-ramps across LATAM", + score = 0.65f, + createdAt = "Yesterday", + isTrending = false, + tags = createArticleTags(), + isViewed = false, + ), + ArticleConfigUM( + id = 5, + title = "Stablecoins expand on-ramps across LATAM", + score = 0.65f, + createdAt = "Yesterday", + isTrending = false, + tags = createArticleTags(), + isViewed = false, + ), + ArticleConfigUM( + id = 6, + title = "Stablecoins expand on-ramps across LATAM", + score = 0.65f, + createdAt = "Yesterday", + isTrending = false, + tags = createArticleTags(), + isViewed = false, + ), + ) + + private fun createArticleTags(): ImmutableSet { + return persistentSetOf( + ArticleTagUM.Token( + title = TextReference.Str("BTC"), + iconState = CurrencyIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + ArticleTagUM.Category(TextReference.Str("Regulation")), + ArticleTagUM.Category(TextReference.Str("BTC")), + ArticleTagUM.Category(TextReference.Str("Supply")), + ArticleTagUM.Category(TextReference.Str("Demand")), + ) + } + + private fun createSampleMarketItems(): ImmutableList = persistentListOf( + createMarketItem( + id = "btc", + name = "Bitcoin", + symbol = "BTC", + trendType = PriceChangeType.UP, + rating = "1", + marketCap = "$1.2T", + percent = "12.3%", + ), + createMarketItem( + id = "eth", + name = "Ethereum", + symbol = "ETH", + trendType = PriceChangeType.DOWN, + rating = "2", + marketCap = "$480B", + percent = "-3.1%", + ), + createMarketItem( + id = "sol", + name = "Solana", + symbol = "SOL", + trendType = PriceChangeType.NEUTRAL, + rating = "7", + marketCap = "$110B", + percent = "0.4%", + ), + createMarketItem( + id = "bnb", + name = "BNB", + symbol = "BNB", + trendType = PriceChangeType.NEUTRAL, + rating = "7", + marketCap = "$110B", + percent = "0.4%", + ), + createMarketItem( + id = "doge", + name = "Dodge", + symbol = "DOG", + trendType = PriceChangeType.NEUTRAL, + rating = "7", + marketCap = "$110B", + percent = "0.4%", + ), + ) + + @Suppress("LongParameterList") + private fun createMarketItem( + id: String, + name: String, + symbol: String, + trendType: PriceChangeType, + rating: String, + marketCap: String, + percent: String, + ): MarketsListItemUM { + return MarketsListItemUM( + id = CryptoCurrency.RawID(id), + name = name, + currencySymbol = symbol, + iconUrl = null, + ratingPosition = rating, + marketCap = marketCap, + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = percent, + trendType = trendType, + chartData = MarketChartRawData( + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), + ), + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt new file mode 100644 index 0000000000..7e002be1f4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -0,0 +1,55 @@ +package com.tangem.features.feed.ui.feed.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.event.StateEvent +import com.tangem.features.feed.ui.market.state.MarketsListItemUM +import com.tangem.features.feed.ui.market.state.SortByTypeUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toPersistentList + +internal data class FeedListUM( + val currentDate: String, + val searchBar: SearchBarUM, + val feedListCallbacks: FeedListCallbacks, + val news: ImmutableList, + val trendingArticle: ArticleConfigUM?, + val marketChartConfig: MarketChartConfig, +) + +internal data class FeedListCallbacks( + val onSearchClick: () -> Unit, + val onMarketOpenClick: (sortBy: SortByTypeUM) -> Unit, + val onArticleClick: (id: Int) -> Unit, + val onOpenAllNews: () -> Unit, + val onMarketItemClick: (MarketsListItemUM) -> Unit, + val onSortTypeClick: (SortByTypeUM) -> Unit, +) + +internal data class MarketChartConfig( + val marketCharts: ImmutableMap, + val currentSortByType: SortByTypeUM = SortByTypeUM.TopGainers, +) { + fun getFilterPreset() = SortByTypeUM.entries.filter { it != SortByTypeUM.Rating }.toPersistentList() +} + +@Immutable +internal sealed interface MarketChartUM { + + data class Content( + val items: ImmutableList, + val triggerScrollReset: StateEvent, + val sortChartConfig: SortChartConfigUM, + ) : MarketChartUM + + data object Loading : MarketChartUM + + data class LoadingError(val onRetryClicked: () -> Unit) : MarketChartUM +} + +data class SortChartConfigUM( + val sortByType: SortByTypeUM, + val isSelected: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt new file mode 100644 index 0000000000..9f011aa99a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt @@ -0,0 +1,322 @@ +package com.tangem.features.feed.ui.market.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.tangem.common.ui.charts.MarketChartMini +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.tokens.TokenPriceText +import com.tangem.core.ui.R +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.LocalWindowSize +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.MarketsTestTags +import com.tangem.core.ui.windowsize.WindowSizeType +import com.tangem.features.feed.ui.market.preview.MarketChartListItemPreviewDataProvider +import com.tangem.features.feed.ui.market.state.MarketsListItemUM +import com.tangem.utils.StringsSigns.MINUS +import kotlin.random.Random + +@Composable +internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + MarketsListItemContent( + modifier = modifier + .fillMaxWidth() + .clip(RectangleShape) + .clickable(onClick = onClick) + .testTag(MarketsTestTags.TOKENS_LIST_ITEM), + model = model, + ) +} + +@Composable +private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) { + val windowSize = LocalWindowSize.current + + Row( + modifier = modifier.padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing15, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + CoinIcon( + modifier = Modifier.size(TangemTheme.dimens.size36), + url = model.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + + SpacerW12() + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TokenTitle( + modifier = Modifier.weight(1f, fill = false), + name = model.name, + currencySymbol = model.currencySymbol, + ) + SpacerW8() + TokenPriceText( + modifier = Modifier.alignByBaseline(), + price = model.price.text, + priceChangeType = model.price.changeType, + ) + } + + SpacerH(height = TangemTheme.dimens.spacing2) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + TokenSubtitle( + modifier = Modifier + .weight(1f, fill = false) + .alignByBaseline(), + ratingPosition = model.ratingPosition, + marketCap = model.marketCap, + stakingRate = model.stakingRate, + ) + PriceChangeInPercent( + modifier = Modifier.alignByBaseline(), + textStyle = TangemTheme.typography.caption2, + type = model.trendType, + valueInPercent = model.trendPercentText, + ) + } + } + + if (windowSize.widthAtLeast(WindowSizeType.Small)) { + Spacer(Modifier.width(TangemTheme.dimens.spacing10)) + + Chart( + chartType = model.chartType, + chartRawData = model.chartData, + ) + } + } +} + +@Composable +private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) { + Row(modifier = modifier) { + Text( + modifier = Modifier + .weight(1f, fill = false) + .alignByBaseline(), + text = name, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + SpacerW4() + Text( + modifier = Modifier.alignByBaseline(), + text = currencySymbol, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption1, + maxLines = 1, + overflow = TextOverflow.Visible, + ) + } +} + +@Composable +private fun TokenSubtitle( + ratingPosition: String?, + marketCap: String?, + stakingRate: TextReference?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + TokenRatingPlace(ratingPosition = ratingPosition) + if (marketCap != null) { + SpacerW4() + TokenMarketCapText( + modifier = Modifier.weight(1f, fill = false), + text = marketCap, + ) + } + if (stakingRate != null) { + SpacerW4() + StakingRate(stakingRate = stakingRate.resolveReference()) + } + } +} + +@Composable +private fun RowScope.TokenRatingPlace(ratingPosition: String?) { + Box( + modifier = Modifier + .alignByBaseline() + .heightIn(min = TangemTheme.dimens.size16) + .background( + color = TangemTheme.colors.field.primary, + shape = TangemTheme.shapes.roundedCornersSmall2, + ) + .padding(horizontal = TangemTheme.dimens.spacing5), + ) { + Text( + text = ratingPosition ?: MINUS, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption1, + maxLines = 1, + ) + } +} + +@Composable +private fun RowScope.StakingRate(stakingRate: String) { + Box( + modifier = Modifier + .alignByBaseline() + .heightIn(min = TangemTheme.dimens.size16) + .border( + width = TangemTheme.dimens.size1, + color = TangemTheme.colors.field.primary, + shape = TangemTheme.shapes.roundedCornersSmall2, + ) + .padding(horizontal = TangemTheme.dimens.spacing5), + ) { + Text( + text = stakingRate, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption1, + maxLines = 1, + ) + } +} + +@Composable +private fun RowScope.TokenMarketCapText(text: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier.alignByBaseline(), + text = text, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) { + val chartWidth = TangemTheme.dimens.size56 + Box( + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing2) + .size(height = TangemTheme.dimens.size24, width = chartWidth), + ) { + if (chartRawData != null) { + MarketChartMini( + rawData = chartRawData, + type = chartType, + ) + } else { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(TangemTheme.dimens.size12) + .align(Alignment.Center), + radius = TangemTheme.dimens.radius3, + ) + } + } +} + +// region preview +@Preview(showBackground = true, widthDp = 360, name = "normal") +@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 260, name = "small width") +@Composable +private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) { + TangemThemePreview { + var state1 by remember { mutableStateOf(state) } + var state2 by remember { mutableStateOf(state) } + var prices by remember { + mutableStateOf( + listOf( + 100 to PriceChangeType.NEUTRAL, + 200 to PriceChangeType.NEUTRAL, + ), + ) + } + + Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) { + MarketsListItem( + modifier = Modifier, + model = state1, + ) + MarketsListItem( + modifier = Modifier, + model = state2, + ) + Row { + Button( + onClick = { + state1 = state1.copy( + trendType = PriceChangeType.entries.random(), + ) + state2 = state2.copy( + trendType = PriceChangeType.entries.random(), + ) + }, + ) { Text(text = "trend") } + + Button( + onClick = { + prices = prices.map { (price, _) -> + if (Random.nextBoolean()) { + price.inc() to PriceChangeType.UP + } else { + price.dec() to PriceChangeType.DOWN + } + } + state1 = state1.copy( + price = MarketsListItemUM.Price( + text = "0.${prices[0].first}023 $", + changeType = prices[0].second, + ), + ) + state2 = state2.copy( + price = MarketsListItemUM.Price( + text = "0.${prices[1].first}023 $", + changeType = prices[1].second, + ), + ) + }, + ) { Text(text = "price") } + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt new file mode 100644 index 0000000000..ab52d344f0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt @@ -0,0 +1,99 @@ +package com.tangem.features.feed.ui.market.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.res.LocalWindowSize +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.windowsize.WindowSizeType + +@Suppress("LongMethod") +@Composable +fun MarketsListItemPlaceholder() { + val density = LocalDensity.current + val windowSize = LocalWindowSize.current + val sp12 = with(density) { 12.sp.toDp() } + + Row( + modifier = Modifier.padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing15, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + CircleShimmer(Modifier.size(TangemTheme.dimens.size36)) + + SpacerW12() + + Column(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens.spacing4), + contentAlignment = Alignment.CenterStart, + ) { + RectangleShimmer( + modifier = Modifier + .width(TangemTheme.dimens.size70) + .height(sp12), + radius = TangemTheme.dimens.radius3, + ) + } + + SpacerH(height = TangemTheme.dimens.spacing2) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens.spacing2), + contentAlignment = Alignment.CenterStart, + ) { + RectangleShimmer( + modifier = Modifier + .width(TangemTheme.dimens.size52) + .height(sp12), + radius = TangemTheme.dimens.radius3, + ) + } + } + + if (windowSize.widthAtLeast(WindowSizeType.Small)) { + Spacer(Modifier.width(TangemTheme.dimens.spacing10)) + + Box { + RectangleShimmer( + modifier = Modifier + .align(Alignment.Center) + .width(TangemTheme.dimens.size56) + .height(TangemTheme.dimens.size12), + radius = TangemTheme.dimens.radius3, + ) + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, name = "normal") +@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 320, name = "small width") +@Composable +private fun Preview() { + TangemThemePreview { + Column(Modifier.background(TangemTheme.colors.background.tertiary)) { + repeat(20) { + MarketsListItemPlaceholder() + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt new file mode 100644 index 0000000000..98dd938ce2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt @@ -0,0 +1,115 @@ +package com.tangem.features.feed.ui.market.preview + +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.ui.market.state.MarketsListItemUM +import kotlinx.collections.immutable.persistentListOf + +@Suppress("MagicNumber") +internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider( + collection = listOf( + MarketsListItemUM( + id = CryptoCurrency.RawID("1"), + name = "Bitcoin", + currencySymbol = "BTC", + iconUrl = "", + ratingPosition = "10", + marketCap = "$6.233 B", + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = PriceChangeType.UP, + chartData = MarketChartRawData( + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), + ), + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ), + MarketsListItemUM( + id = CryptoCurrency.RawID("1"), + name = "Bitcoin", + currencySymbol = "BTC", + iconUrl = null, + ratingPosition = "10", + marketCap = "$6.233 B", + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = PriceChangeType.NEUTRAL, + chartData = null, + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ), + MarketsListItemUM( + id = CryptoCurrency.RawID("1"), + name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", + currencySymbol = "BTC", + iconUrl = null, + ratingPosition = "10", + marketCap = "$6.23348172384781234 B", + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = PriceChangeType.DOWN, + chartData = MarketChartRawData( + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), + ), + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ), + MarketsListItemUM( + id = CryptoCurrency.RawID("1"), + name = "Bitcoin", + currencySymbol = "BTC", + iconUrl = null, + ratingPosition = "10", + marketCap = null, + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = PriceChangeType.UP, + chartData = MarketChartRawData( + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), + ), + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ), + MarketsListItemUM( + id = CryptoCurrency.RawID("1"), + name = "Bitcoin", + currencySymbol = "BTC", + iconUrl = null, + ratingPosition = null, + marketCap = "$6.233 B", + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = PriceChangeType.UP, + chartData = MarketChartRawData( + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), + ), + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ), + MarketsListItemUM( + id = CryptoCurrency.RawID("1"), + name = "Bitcoin", + currencySymbol = "BTC", + iconUrl = null, + ratingPosition = null, + marketCap = null, + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = PriceChangeType.UP, + chartData = MarketChartRawData( + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), + ), + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ), + ), +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt new file mode 100644 index 0000000000..5e518373dc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt @@ -0,0 +1,46 @@ +package com.tangem.features.feed.ui.market.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.currency.CryptoCurrency + +@Immutable +data class MarketsListItemUM( + val id: CryptoCurrency.RawID, + val name: String, + val currencySymbol: String, + val iconUrl: String?, + val ratingPosition: String?, + val marketCap: String?, + val price: Price, + val trendPercentText: String, + val trendType: PriceChangeType, + val chartData: MarketChartRawData?, + val isUnder100kMarketCap: Boolean, + val stakingRate: TextReference?, + val updateTimestamp: Long?, +) { + val chartType: MarketChartLook.Type = when (trendType) { + PriceChangeType.UP -> MarketChartLook.Type.Growing + PriceChangeType.DOWN -> MarketChartLook.Type.Falling + PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral + } + + @Immutable + data class Price( + val text: String, + val changeType: PriceChangeType? = null, + ) + + @Suppress("NullableToStringCall") + fun getComposeKey(): String { + return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.toString() + updateTimestamp + } + + companion object { + const val TOKEN_LAZY_LIST_ID_SEPARATOR = "@" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt new file mode 100644 index 0000000000..ea7e5f740a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt @@ -0,0 +1,66 @@ +package com.tangem.features.feed.ui.market.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.currency.CryptoCurrency +import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal + +internal data class MarketsListUM( + val list: ListUM, + val searchBar: SearchBarUM, + val selectedSortBy: SortByTypeUM, + val sortByBottomSheet: TangemBottomSheetConfig, + val selectedInterval: TrendInterval, + val onIntervalClick: (TrendInterval) -> Unit, + val onSortByButtonClick: () -> Unit, + val stakingNotificationMaxApy: BigDecimal?, + val onStakingNotificationClick: () -> Unit, + val onStakingNotificationCloseClick: () -> Unit, +) { + val isInSearchMode + get() = searchBar.isActive + + enum class TrendInterval(val text: TextReference) { + H24(resourceReference(R.string.markets_selector_interval_24h_title)), + D7(resourceReference(R.string.markets_selector_interval_7d_title)), + M1(resourceReference(R.string.markets_selector_interval_1m_title)), + } +} + +enum class SortByTypeUM(val text: TextReference) { + Rating(resourceReference(R.string.markets_sort_by_rating_title)), + Trending(resourceReference(R.string.markets_sort_by_trending_title)), + ExperiencedBuyers(resourceReference(R.string.markets_sort_by_experienced_buyers_title)), + TopGainers(resourceReference(R.string.markets_sort_by_top_gainers_title)), + TopLosers(resourceReference(R.string.markets_sort_by_top_losers_title)), + Staking(resourceReference(R.string.common_staking)), +} + +@Immutable +sealed class ListUM { + + data class Content( + val items: ImmutableList, + val shouldShowUnder100kTokensNotification: Boolean, + val shouldShowUnder100kTokensNotificationWasHidden: Boolean, + val loadMore: () -> Unit, + val visibleIdsChanged: (List) -> Unit, + val onShowTokensUnder100kClicked: () -> Unit, + val triggerScrollReset: StateEvent, + val onItemClick: (MarketsListItemUM) -> Unit, + ) : ListUM() + + data object Loading : ListUM() + + data class LoadingError( + val onRetryClicked: () -> Unit, + ) : ListUM() + + data object SearchNothingFound : ListUM() +} \ No newline at end of file diff --git a/features/home/impl/detekt-baseline-debug.xml b/features/home/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..684e103473 --- /dev/null +++ b/features/home/impl/detekt-baseline-debug.xml @@ -0,0 +1,14 @@ + + + + + BooleanPropertyNaming:HomeButtons.kt$HomeButtonsState$val btnScanStateInProgress: Boolean + BooleanPropertyNaming:HomeUM.kt$HomeUM$val scanInProgress: Boolean + MultilineLambdaItParameter:HomeModel.kt$HomeModel${ delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet) } } + MultilineLambdaItParameter:StoriesProgressBar.kt${ when (index) { currentStep -> it.fillMaxWidth(progress.value) in 0 until currentStep -> it.fillMaxWidth(fraction = 1f) else -> it } } + NonBooleanPropertyPrefixedWithIs:StoriesAnimation.kt$val isFirstStepLaunched = remember { mutableStateOf(false) } + NonBooleanPropertyPrefixedWithIs:StoriesAnimation.kt$val isLaunched = remember { mutableStateOf(false) } + NonBooleanPropertyPrefixedWithIs:StoriesAnimation.kt$val isSecondStepLaunched = remember { mutableStateOf(false) } + ReusedModifierInstance:HomeButtonsV2.kt$StoriesButton( modifier = modifier, text = stringResourceSafe(id = R.string.common_get_started), useDarkerColors = false, onClick = onGetStartedClick, ) + + 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..4ef515352f 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,10 @@ interface CreateWalletBackupComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, + val isUpgradeFlow: Boolean, + val shouldSetAccessCode: Boolean, + val analyticsSource: String, + val analyticsAction: String, ) 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/UpdateAccessCodeComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpdateAccessCodeComponent.kt index b2416c8d0a..a6d223bd14 100644 --- a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpdateAccessCodeComponent.kt +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpdateAccessCodeComponent.kt @@ -5,6 +5,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.wallet.UserWalletId interface UpdateAccessCodeComponent : ComposableContentComponent { - data class Params(val userWalletId: UserWalletId) + data class Params( + val userWalletId: UserWalletId, + val source: String, + ) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/WalletActivationComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/WalletActivationComponent.kt index 8b99c19f36..b92f94c6e0 100644 --- a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/WalletActivationComponent.kt +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/WalletActivationComponent.kt @@ -8,6 +8,7 @@ interface WalletActivationComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, + val isBackupExists: Boolean, ) interface Factory : ComponentFactory 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..380b51678e 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 */ @@ -36,6 +37,7 @@ dependencies { implementation(projects.domain.settings) implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) + implementation(projects.domain.hotWallet) /** Common */ implementation(projects.common.ui) diff --git a/features/hot-wallet/impl/detekt-baseline-debug.xml b/features/hot-wallet/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..897f428e06 --- /dev/null +++ b/features/hot-wallet/impl/detekt-baseline-debug.xml @@ -0,0 +1,63 @@ + + + + + BooleanPropertyNaming:AccessCodeModel.kt$AccessCodeModel$var buttonClicked = false + BooleanPropertyNaming:AddExistingWalletImportUM.kt$AddExistingWalletImportUM$val importWalletEnabled: Boolean + BooleanPropertyNaming:AddExistingWalletImportUM.kt$AddExistingWalletImportUM$val importWalletProgress: Boolean + BooleanPropertyNaming:AddExistingWalletImportUM.kt$AddExistingWalletImportUM$val readyToImport: Boolean + BooleanPropertyNaming:CreateMobileWalletUM.kt$CreateMobileWalletUM$val createButtonLoading: Boolean + BooleanPropertyNaming:ForgetWalletUM.kt$ForgetWalletUM$val firstCheckboxChecked: Boolean + BooleanPropertyNaming:ForgetWalletUM.kt$ForgetWalletUM$val secondCheckboxChecked: Boolean + BooleanPropertyNaming:HotAccessCodeRequestUM.kt$HotAccessCodeRequestUM$val useBiometricVisible: Boolean = true + BooleanPropertyNaming:HotWalletStepperComponent.kt$HotWalletStepperComponent.StepperUM$val showBackButton: Boolean + BooleanPropertyNaming:HotWalletStepperComponent.kt$HotWalletStepperComponent.StepperUM$val showFeedbackButton: Boolean + BooleanPropertyNaming:HotWalletStepperComponent.kt$HotWalletStepperComponent.StepperUM$val showSkipButton: Boolean + BooleanPropertyNaming:ManualBackupCheckUM.kt$ManualBackupCheckUM$val completeButtonEnabled: Boolean + BooleanPropertyNaming:ManualBackupCheckUM.kt$ManualBackupCheckUM$val completeButtonProgress: Boolean + BooleanPropertyNaming:ManualBackupCheckUM.kt$ManualBackupCheckUM.WordField$val error: Boolean + BooleanPropertyNaming:MobileWalletSetupFinishedContent.kt$var showConfetti by remember { mutableStateOf(false) } + BooleanPropertyNaming:UpgradeWalletModel.kt$UpgradeWalletModel$val otherWalletAndAlreadyCreated by lazy { userWallet?.walletId != params.userWalletId && it.card.wallets.map { it.curve }.toSet().isNotEmpty() } + BooleanPropertyNaming:UpgradeWalletModel.kt$UpgradeWalletModel$val sameWalletButNotFinishedBackup by lazy { userWallet?.walletId == params.userWalletId && BackupValidator.isValidFull(it.card).not() } + BooleanPropertyNaming:WalletBackupUM.kt$WalletBackupUM$val backedUp: Boolean + BooleanPropertyNaming:WalletHardwareBackupUM.kt$WalletHardwareBackupUM$val showPurchaseBlock: Boolean = false + MaxChainedCallsOnSameLine:UpgradeWalletModel.kt$UpgradeWalletModel$it.card.wallets.map { it.curve }.toSet().isNotEmpty() + MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ Timber.e(it) setImportProgress(false) } + MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ setImportProgress(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -> { uiMessageSender.send( SnackbarMessage(resourceReference(R.string.hw_import_seed_phrase_already_imported)), ) } } } + MultilineLambdaItParameter:CreateHardwareWalletModel.kt$CreateHardwareWalletModel${ delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -> { userWalletsListRepository.unlock( userWalletId = userWallet.walletId, unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), ).onRight { router.replaceAll(AppRoute.Wallet) } } } } + MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ Timber.e("Unable to delete wallet: $it") uiMessageSender.send( message = SnackbarMessage(resourceReference(R.string.common_unknown_error)), ) return@launch } + MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ val newValue = !it.firstCheckboxChecked it.copy( firstCheckboxChecked = newValue, isForgetButtonEnabled = newValue && it.secondCheckboxChecked, ) } + MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ val newValue = !it.secondCheckboxChecked it.copy( secondCheckboxChecked = newValue, isForgetButtonEnabled = it.firstCheckboxChecked && newValue, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( accessCode = accessCode, accessCodeColor = PinTextColor.Primary, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( accessCodeColor = PinTextColor.Success, onAccessCodeChange = {}, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( accessCodeColor = PinTextColor.WrongCode, onAccessCodeChange = {}, useBiometricVisible = currentRequest.isBiometryButtonVisible(), ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( isShown = true, accessCode = "", useBiometricVisible = attemptRequest.isBiometryButtonVisible(), onAccessCodeChange = ::onAccessCodeChange, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( onAccessCodeChange = {}, useBiometricVisible = false, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( wrongAccessCodeText = null, onAccessCodeChange = ::onAccessCodeChange, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( wrongAccessCodeText = remainingSecondsToText(attempts.remainingSeconds) ?: resourceReference( R.string.access_code_check_warining_delete, wrappedList(attempts.remainingAttemptsCountBeforeDeletion), ), onAccessCodeChange = ::onAccessCodeChange.takeIf { attempts.remainingSeconds <= 0 } ?: {}, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( wrongAccessCodeText = remainingSecondsToText(attempts.remainingSeconds), onAccessCodeChange = ::onAccessCodeChange.takeIf { attempts.remainingSeconds <= 0 } ?: {}, ) } + MultilineLambdaItParameter:HotAccessCodeRequestModel.kt$HotAccessCodeRequestModel${ it.copy( wrongAccessCodeText = resourceReference( R.string.access_code_check_warining_lock, wrappedList(MAX_FAST_FORWARD_ATTEMPTS - attempts.count), ), onAccessCodeChange = ::onAccessCodeChange, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( importWalletEnabled = false, wordsErrorText = null, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( invalidWords = emptyList<String>().toImmutableList(), wordsErrorText = null, importWalletEnabled = true, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( invalidWords = invalidWords.toImmutableList(), wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_wrong_words), importWalletEnabled = false, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_invalid_checksum), importWalletEnabled = false, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ launchInterceptWords(wordsField = it) suggestNextWord(it) updateUiState { state -> state.copy(words = it) } } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ passphrase = it.text updateUiState { state -> state.copy(passPhrase = it) } } + MultilineLambdaItParameter:InvalidWordsColorTransformation.kt$InvalidWordsColorTransformation${ if (it == WORD_SEPARATOR) { append(WHITE_SPACE) } else if (wordsToBrush.contains(it)) { append(it.annotate()) } else { append(it) } } + MultilineLambdaItParameter:ManualBackupCheckModel.kt$ManualBackupCheckModel${ Timber.e(it) uiState.update { it.copy(completeButtonProgress = false) } } + MultilineLambdaItParameter:ManualBackupCheckModel.kt$ManualBackupCheckModel${ it.copy( words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.filterIndexed { index, _ -> WORD_FIELD_INDICES.contains(index + 1) }.toImmutableList(), ) } + MultilineLambdaItParameter:ManualBackupPhraseContent.kt${ EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word${it + 1}", ) } + MultilineLambdaItParameter:ManualBackupPhraseModel.kt$ManualBackupPhraseModel${ it.copy( words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.mapIndexed { index, s -> EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList(), ) } + MultilineLambdaItParameter:UpgradeWalletModel.kt$UpgradeWalletModel${ // Check if user attempted to upgrade before but something went wrong and a full reset is required val userWallet = coldUserWalletBuilderFactory.create(it).build() val sameWalletButNotFinishedBackup by lazy { userWallet?.walletId == params.userWalletId && BackupValidator.isValidFull(it.card).not() } val otherWalletAndAlreadyCreated by lazy { userWallet?.walletId != params.userWalletId && it.card.wallets.map { it.curve }.toSet().isNotEmpty() } if (userWallet != null && (sameWalletButNotFinishedBackup || otherWalletAndAlreadyCreated)) { startResetCardsFlow.emit(userWallet) return@doOnSuccess } delay(DELAY_SDK_DIALOG_CLOSE) tangemSdkManager.changeDisplayedCardIdNumbersCount(it) navigateToUpgradeFlow(it) } + MultilineLambdaItParameter:ViewPhraseContent.kt${ EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word${it + 1}", ) } + MultilineLambdaItParameter:ViewPhraseModel.kt$ViewPhraseModel${ it.copy( words = words.mapIndexed { index, s -> EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList(), ) } + NoNameShadowing:ManualBackupCheckModel.kt$ManualBackupCheckModel${ it.copy(completeButtonProgress = false) } + PropertyUsedBeforeDeclaration:AddExistingWalletImportModel.kt$AddExistingWalletImportModel$uiState + ReusedModifierInstance:AddExistingWalletImportContent.kt$OutlineTextFieldWithIcon( modifier = modifier .padding(horizontal = 16.dp) .fillMaxWidth(), value = state.passPhrase, onValueChange = state.passPhraseChange, iconResId = R.drawable.ic_information_24, iconColor = TangemTheme.colors.icon.informative, label = stringResourceSafe(id = R.string.common_passphrase), placeholder = stringResourceSafe(id = R.string.send_optional_field), onIconClick = state.onPassphraseInfoClick, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, keyboardType = KeyboardType.Password, ), ) + ReusedModifierInstance:HotAccessCodeRequestFullScreenContent.kt$AnimatedVisibility( modifier = modifier, visible = state.isShown, enter = fadeIn(), exit = fadeOut(), ) { Column( Modifier .fillMaxSize() .background(TangemTheme.colors.background.primary), horizontalAlignment = Alignment.CenterHorizontally, ) { TangemTopAppBar( modifier = Modifier.statusBarsPadding(), startButton = TopAppBarButtonUM.Back(state.onDismiss), ) SpacerH(68.dp) Column( Modifier .weight(1f) .fillMaxWidth() .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( modifier = Modifier.animateEnterExit( enter = slideInVertically( tween(), initialOffsetY = { it + 200 }, ) + fadeIn(tween()), exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()), ), text = stringResourceSafe(R.string.access_code_check_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, ) SpacerH24() PinTextField( modifier = Modifier.animateEnterExit( enter = slideInVertically( tween(), initialOffsetY = { it + 200 }, ) + fadeIn(tween()), exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()), ), length = 6, isPasswordVisual = true, value = state.accessCode, pinTextColor = state.accessCodeColor, onValueChange = state.onAccessCodeChange, ) SpacerH(20.dp) AnimatedVisibility( modifier = Modifier.animateEnterExit( enter = slideInVertically( tween(), initialOffsetY = { it + 200 }, ) + fadeIn(tween()), exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()), ), visible = state.wrongAccessCodeText != null, enter = fadeIn(), exit = fadeOut(), ) { val wrongAccessCodeText = state.wrongAccessCodeText ?: return@AnimatedVisibility Text( text = wrongAccessCodeText.resolveReference(), textAlign = TextAlign.Center, style = TangemTheme.typography.caption2.copy( lineBreak = LineBreak.Heading, ), color = TangemTheme.colors.text.warning, ) } } AnimatedVisibility( visible = state.useBiometricVisible, enter = fadeIn(), exit = fadeOut(), ) { SecondaryButton( modifier = Modifier .padding(16.dp) .fillMaxWidth() .navigationBarsPadding() .imePadding(), text = stringResourceSafe( id = R.string.welcome_unlock, stringResourceSafe(R.string.common_biometrics), ), onClick = state.useBiometricClick, ) } } } + ReusedModifierInstance:HotWalletStepper.kt$TangemTopAppBar( startButton = if (state.showBackButton) { TopAppBarButtonUM.Back(onBackClick) } else { null }, endButton = when { state.showSkipButton -> TopAppBarButtonUM.Text( text = resourceReference(R.string.common_skip), onClicked = onSkipClick, ) state.showFeedbackButton -> TopAppBarButtonUM.Icon( iconRes = R.drawable.ic_chat_24, onClicked = onFeedbackClick, ) else -> null }, title = state.title, containerColor = TangemTheme.colors.background.primary, modifier = modifier, titleAlignment = Alignment.CenterHorizontally, ) + SuspendFunSwallowedCancellation:AddExistingWalletImportModel.kt$AddExistingWalletImportModel$runCatching + SuspendFunSwallowedCancellation:ManualBackupCheckModel.kt$ManualBackupCheckModel$runCatching + SuspendFunSwallowedCancellation:ManualBackupPhraseModel.kt$ManualBackupPhraseModel$runCatching + + diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt index b3f1b8afa4..ec2f7d3ca4 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.essenty.lifecycle.doOnResume import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent @@ -21,6 +22,12 @@ internal class AccessCodeComponent @AssistedInject constructor( private val model: AccessCodeModel = getOrCreateModel(params) + init { + lifecycle.doOnResume { + model.onResume() + } + } + @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/accesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt index 066416eb67..318bce1dc7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/AccessCodeModel.kt @@ -16,6 +16,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.SetAskBiometryShownUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase import com.tangem.domain.wallets.usecase.GetHotWalletContextualUnlockUseCase @@ -24,18 +25,22 @@ import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM import com.tangem.features.hotwallet.impl.R import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth -import com.tangem.hot.sdk.model.UnlockHotWallet +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.resume +private const val SUCCESS_DISPLAY_DURATION_MS = 400L +private const val ERROR_DISPLAY_DURATION_MS = 500L + @Suppress("LongParameterList") @Stable @ModelScoped @@ -45,6 +50,7 @@ internal class AccessCodeModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val getHotWalletContextualUnlockUseCase: GetHotWalletContextualUnlockUseCase, private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, + private val hotWalletAccessor: HotWalletAccessor, private val userWalletsListRepository: UserWalletsListRepository, private val walletsRepository: WalletsRepository, private val tangemHotSdk: TangemHotSdk, @@ -59,70 +65,129 @@ internal class AccessCodeModel @Inject constructor( internal val uiState: StateFlow field = MutableStateFlow(getInitialState()) + internal fun onResume() { + uiState.update { currentState -> + currentState.copy( + onAccessCodeChange = ::onAccessCodeChange, + ) + } + } + private fun getInitialState() = AccessCodeUM( accessCode = "", accessCodeColor = PinTextColor.Primary, onAccessCodeChange = ::onAccessCodeChange, isConfirmMode = params.accessCodeToConfirm != null, - buttonEnabled = false, - buttonInProgress = false, - onButtonClick = ::onButtonClick, ) private fun onAccessCodeChange(value: String) { - uiState.update { - it.copy( + val isConfirmMode = uiState.value.isConfirmMode + val accessCodeLength = uiState.value.accessCodeLength + + if (value.length > accessCodeLength) return + + uiState.update { currentState -> + currentState.copy( accessCode = value, - buttonEnabled = if (params.accessCodeToConfirm != null) { - value == params.accessCodeToConfirm - } else { - value.length == uiState.value.accessCodeLength - }, - accessCodeColor = when { - params.accessCodeToConfirm == null -> PinTextColor.Primary - value.length != uiState.value.accessCodeLength -> PinTextColor.Primary - value == params.accessCodeToConfirm -> PinTextColor.Success - else -> PinTextColor.WrongCode - }, + accessCodeColor = PinTextColor.Primary, ) } + + if (value.length == accessCodeLength) { + uiState.update { currentState -> + currentState.copy( + onAccessCodeChange = {}, + ) + } + + if (isConfirmMode) { + modelScope.launch { + if (value == params.accessCodeToConfirm) { + setCode(params.userWalletId, params.accessCodeToConfirm) + } else { + showErrorAndReset() + } + } + } else { + onNewCodeSet() + } + } } - private fun onButtonClick() { - if (params.accessCodeToConfirm == null) { + private fun onNewCodeSet() { + modelScope.launch { + delay(timeMillis = SUCCESS_DISPLAY_DURATION_MS) + params.callbacks.onNewAccessCodeInput(params.userWalletId, uiState.value.accessCode) - } else { - setCode(params.userWalletId, params.accessCodeToConfirm) + + uiState.update { currentState -> + currentState.copy( + accessCode = "", + ) + } + } + } + + private suspend fun showErrorAndReset() { + uiState.update { currentState -> + currentState.copy( + accessCodeColor = PinTextColor.WrongCode, + ) + } + delay(timeMillis = ERROR_DISPLAY_DURATION_MS) + uiState.update { currentState -> + currentState.copy( + accessCode = "", + accessCodeColor = PinTextColor.Primary, + onAccessCodeChange = ::onAccessCodeChange, + ) } } private fun setCode(userWalletId: UserWalletId, accessCode: String) { modelScope.launch { - uiState.update { - it.copy(buttonInProgress = true) + val userWallet = getUserWalletUseCase(userWalletId) + .getOrElse { error("User wallet with id $userWalletId not found") } + if (userWallet !is UserWallet.Hot) return@launch + + tryToAskForBiometry() + + userWalletsListRepository.saveWithoutLock( + userWallet.copy(backedUp = true), + canOverride = true, + ) + + userWalletsListRepository.setLock( + userWallet.walletId, + UserWalletsListRepository.LockMethod.AccessCode(accessCode.toCharArray()), + ) + + if (walletsRepository.useBiometricAuthentication()) { + userWalletsListRepository.setLock( + userWallet.walletId, + UserWalletsListRepository.LockMethod.Biometric, + ) } - runCatching { - val userWallet = getUserWalletUseCase(userWalletId) - .getOrElse { error("User wallet with id $userWalletId not found") } - if (userWallet !is UserWallet.Hot) return@launch + val unlockHotWallet = getHotWalletContextualUnlockUseCase(userWallet.hotWalletId) + .getOrNull() + ?: run { + require(userWallet.hotWalletId.authType == HotWalletId.AuthType.NoPassword) { + "Something went wrong. Hot wallet is locked and cannot be unlocked with NoAuth" + } - val unlockHotWallet = getHotWalletContextualUnlockUseCase(userWallet.hotWalletId) - .getOrNull() - ?: UnlockHotWallet(userWallet.hotWalletId, HotAuth.NoAuth) + hotWalletAccessor.unlockContextual(userWallet.hotWalletId) + } + + launch(NonCancellable) { var updatedHotWalletId = tangemHotSdk.changeAuth( unlockHotWallet = unlockHotWallet, auth = HotAuth.Password(accessCode.toCharArray()), ) - tryToAskForBiometry() - if (walletsRepository.requireAccessCode().not()) { updatedHotWalletId = tangemHotSdk.changeAuth( - unlockHotWallet = UnlockHotWallet( - walletId = updatedHotWalletId, - auth = HotAuth.Password(accessCode.toCharArray()), - ), + unlockHotWallet = unlockHotWallet, auth = HotAuth.Biometry, ) } @@ -135,26 +200,10 @@ internal class AccessCodeModel @Inject constructor( canOverride = true, ) - userWalletsListRepository.setLock( - userWallet.walletId, - UserWalletsListRepository.LockMethod.AccessCode(accessCode.toCharArray()), - ) - - if (walletsRepository.useBiometricAuthentication()) { - userWalletsListRepository.setLock( - userWallet.walletId, - UserWalletsListRepository.LockMethod.Biometric, - ) - } - - params.callbacks.onAccessCodeUpdated(params.userWalletId) - }.onFailure { - Timber.e(it) - - uiState.update { - it.copy(buttonInProgress = false) - } + clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) } + + params.callbacks.onAccessCodeUpdated(params.userWalletId) } } @@ -210,9 +259,4 @@ internal class AccessCodeModel @Inject constructor( return canUseBiometry && shouldShowAskBiometry } - - override fun onDestroy() { - clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) - super.onDestroy() - } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/entity/AccessCodeUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/entity/AccessCodeUM.kt index be65b51a49..1bc94c1d7c 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/entity/AccessCodeUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/entity/AccessCodeUM.kt @@ -8,9 +8,6 @@ internal data class AccessCodeUM( val accessCodeColor: PinTextColor, val onAccessCodeChange: (String) -> Unit, val isConfirmMode: Boolean, - val buttonEnabled: Boolean, - val buttonInProgress: Boolean, - val onButtonClick: () -> Unit, ) { val accessCodeLength: Int = ACCESS_CODE_LENGTH } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt index 87161aa57b..5d0526fe6b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.res.R -import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.fields.PinTextColor import com.tangem.core.ui.components.fields.PinTextField import com.tangem.core.ui.extensions.stringResourceSafe @@ -25,6 +24,7 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxSize() + .background(TangemTheme.colors.background.primary) .navigationBarsPadding(), ) { Column( @@ -32,7 +32,6 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) { .padding(top = 16.dp) .weight(1f) .fillMaxSize() - .background(TangemTheme.colors.background.primary) .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -81,23 +80,6 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) { ) } } - - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .imePadding(), - text = stringResourceSafe( - if (state.isConfirmMode) { - R.string.common_confirm - } else { - R.string.common_continue - }, - ), - onClick = state.onButtonClick, - enabled = state.buttonEnabled, - showProgress = state.buttonInProgress, - ) } } @@ -112,9 +94,6 @@ private fun PreviewSet() { accessCodeColor = PinTextColor.Primary, onAccessCodeChange = {}, isConfirmMode = false, - buttonEnabled = false, - buttonInProgress = false, - onButtonClick = {}, ), ) } @@ -131,9 +110,6 @@ private fun PreviewConfirm() { accessCodeColor = PinTextColor.Success, onAccessCodeChange = {}, isConfirmMode = true, - buttonEnabled = true, - buttonInProgress = false, - onButtonClick = {}, ), ) } 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..e40072474a 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,9 @@ package com.tangem.features.hotwallet.addexistingwallet.entry import com.arkivanov.decompose.router.stack.* 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.utils.TrackingContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -13,6 +16,8 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.ShouldAskPermissionUseCase +import com.tangem.domain.hotwallet.SetAccessCodeSkippedUseCase +import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent @@ -26,25 +31,40 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class AddExistingWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val trackingContextProxy: TrackingContextProxy, + private val setAccessCodeSkippedUseCase: SetAccessCodeSkippedUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback() val addExistingWalletImportModelCallbacks = AddExistingWalletImportModelCallbacks() - val manualBackupCompletedComponentModelCallbacks = ManualBackupCompletedComponentModelCallbacks() + val manualBackupCompletedModelCallbacks = ManualBackupCompletedModelCallbacks() val accessCodeModelCallbacks = AccessCodeModelCallbacks() val pushNotificationsCallbacks = PushNotificationsCallbacks() - val mobileWalletSetupFinishedComponentModelCallbacks = MobileWalletSetupFinishedComponentModelCallbacks() + val mobileWalletSetupFinishedModelCallbacks = MobileWalletSetupFinishedModelCallbacks() val stackNavigation = StackNavigation() val startRoute = AddExistingWalletRoute.Import val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + private val analyticsSource = AnalyticsParam.ScreensSources.ImportWallet + + init { + trackingContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + trackingContextProxy.removeContext() + } + fun onChildBack() { when (currentRoute.value) { is AddExistingWalletRoute.Import -> router.pop() @@ -72,6 +92,12 @@ internal class AddExistingWalletModel @Inject constructor( } private fun showSkipAccessCodeWarningDialog() { + val userWalletId = when (val route = currentRoute.value) { + is AddExistingWalletRoute.SetAccessCode -> route.userWalletId + is AddExistingWalletRoute.ConfirmAccessCode -> route.userWalletId + else -> null + } + uiMessageSender.send( DialogMessage( message = resourceReference(R.string.access_code_alert_skip_description), @@ -82,7 +108,14 @@ internal class AddExistingWalletModel @Inject constructor( ), secondAction = EventMessageAction( title = resourceReference(R.string.access_code_alert_skip_ok), - onClick = { navigateToPushNotificationsOrNext() }, + onClick = { + if (userWalletId != null) { + modelScope.launch { + setAccessCodeSkippedUseCase(userWalletId, true) + } + } + navigateToPushNotificationsOrNext() + }, ), shouldDismissOnFirstAction = true, ), @@ -105,14 +138,22 @@ internal class AddExistingWalletModel @Inject constructor( } } - inner class ManualBackupCompletedComponentModelCallbacks : ManualBackupCompletedComponent.ModelCallbacks { + inner class ManualBackupCompletedModelCallbacks : ManualBackupCompletedComponent.ModelCallbacks { override fun onContinueClick(userWalletId: UserWalletId) { + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.AccessCodeScreenOpened(source = analyticsSource.value), + ) stackNavigation.replaceAll(AddExistingWalletRoute.SetAccessCode(userWalletId)) } + + override fun onUpgradeClick(userWalletId: UserWalletId) = Unit } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.ReEnterAccessCodeScreen(source = analyticsSource.value), + ) stackNavigation.push(AddExistingWalletRoute.ConfirmAccessCode(userWalletId, accessCode)) } @@ -135,9 +176,9 @@ internal class AddExistingWalletModel @Inject constructor( } } - inner class MobileWalletSetupFinishedComponentModelCallbacks : + inner class MobileWalletSetupFinishedModelCallbacks : MobileWalletSetupFinishedComponent.ModelCallbacks { - override fun onContinueClick() { + override fun onFinishClick() { router.replaceAll(AppRoute.Wallet) } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletStepperStateManager.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletStepperStateManager.kt index 5d9dab6ae3..587c85328d 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletStepperStateManager.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletStepperStateManager.kt @@ -21,7 +21,7 @@ internal class AddExistingWalletStepperStateManager { is AddExistingWalletRoute.BackupCompleted -> HotWalletStepperComponent.StepperUM( currentStep = STEP_BACKUP, steps = STEPS_COUNT, - title = resourceReference(R.string.common_backup), + title = resourceReference(R.string.wallet_import_title), showBackButton = false, showSkipButton = false, showFeedbackButton = false, 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..90669cb227 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 @@ -33,7 +33,8 @@ internal class AddExistingWalletChildFactory @Inject constructor( context = childContext, params = ManualBackupCompletedComponent.Params( userWalletId = route.userWalletId, - callbacks = model.manualBackupCompletedComponentModelCallbacks, + callbacks = model.manualBackupCompletedModelCallbacks, + isImportFlow = true, ), ) is AddExistingWalletRoute.SetAccessCode -> accessCodeComponentFactory.create( @@ -61,7 +62,7 @@ internal class AddExistingWalletChildFactory @Inject constructor( is AddExistingWalletRoute.SetupFinished -> MobileWalletSetupFinishedComponent( context = childContext, params = MobileWalletSetupFinishedComponent.Params( - callbacks = model.mobileWalletSetupFinishedComponentModelCallbacks, + callbacks = model.mobileWalletSetupFinishedModelCallbacks, ), ) } 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..da08951a67 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt @@ -0,0 +1,188 @@ +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.analytics.WalletSettingsAnalyticEvents +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, + ), + ) + + init { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.CreateWalletScreenOpened) + } + + 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..47d9334760 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,16 +1,20 @@ package com.tangem.features.hotwallet.createmobilewallet import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.TrackingContextProxy 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.wallets.builder.HotUserWalletBuilder import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase import com.tangem.features.hotwallet.createmobilewallet.entity.CreateMobileWalletUM import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.MnemonicType import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -18,13 +22,16 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class CreateMobileWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory, private val saveUserWalletUseCase: SaveWalletUseCase, + private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase, private val router: Router, private val tangemHotSdk: TangemHotSdk, + private val trackingContextProxy: TrackingContextProxy, ) : Model() { internal val uiState: StateFlow @@ -37,6 +44,15 @@ internal class CreateMobileWalletModel @Inject constructor( ), ) + init { + trackingContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + trackingContextProxy.removeContext() + } + private fun onImportClick() { router.push(AppRoute.AddExistingWallet) } @@ -47,18 +63,21 @@ internal class CreateMobileWalletModel @Inject constructor( it.copy(createButtonLoading = true) } - runCatching { + runSuspendCatching { val hotWalletId = tangemHotSdk.generateWallet(HotAuth.NoAuth, mnemonicType = MnemonicType.Words12) val hotUserWalletBuilder = hotUserWalletBuilderFactory.create(hotWalletId) val userWallet = hotUserWalletBuilder.build() - saveUserWalletUseCase(userWallet) - router.replaceAll(AppRoute.Wallet) - }.onFailure { - Timber.e(it) - uiState.update { - it.copy(createButtonLoading = false) + saveUserWalletUseCase(userWallet) + + launch(NonCancellable) { + syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId) } + router.replaceAll(AppRoute.Wallet) + }.onFailure { throwable -> + Timber.e(throwable) + + uiState.update { it.copy(createButtonLoading = false) } } } } 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..99f127b9bf 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 @@ -95,7 +96,12 @@ internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Mo PrimaryButton( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding( + start = 16.dp, + top = 8.dp, + end = 16.dp, + bottom = 16.dp, + ), text = stringResourceSafe(R.string.onboarding_create_wallet_button_create_wallet), showProgress = state.createButtonLoading, onClick = state.onCreateClick, @@ -103,39 +109,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..440dadd85a 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,11 +3,16 @@ 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.api.AnalyticsEventHandler +import com.tangem.core.analytics.utils.TrackingContextProxy 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.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.features.hotwallet.CreateWalletBackupComponent import com.tangem.features.hotwallet.createwalletbackup.routing.CreateWalletBackupRoute import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent @@ -23,6 +28,8 @@ internal class CreateWalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, + private val trackingContextProxy: TrackingContextProxy, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { val params = paramsContainer.require() @@ -36,6 +43,21 @@ internal class CreateWalletBackupModel @Inject constructor( val startRoute = CreateWalletBackupRoute.RecoveryPhraseStart val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + init { + trackingContextProxy.addHotWalletContext() + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.RecoveryPhraseScreenInfo( + source = params.analyticsSource, + action = params.analyticsAction, + ), + ) + } + + override fun onDestroy() { + super.onDestroy() + trackingContextProxy.removeContext() + } + fun onBack() { when (currentRoute.value) { is CreateWalletBackupRoute.RecoveryPhraseStart -> router.pop() @@ -46,19 +68,51 @@ internal class CreateWalletBackupModel @Inject constructor( } fun onManualBackupStarted() { + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.RecoveryPhraseScreen( + source = params.analyticsSource, + action = params.analyticsAction, + ), + ) stackNavigation.push(CreateWalletBackupRoute.RecoveryPhrase) } fun onManualBackupPhraseShown() { + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.RecoveryPhraseCheck( + source = params.analyticsSource, + action = params.analyticsAction, + ), + ) stackNavigation.push(CreateWalletBackupRoute.ConfirmBackup) } fun onManualBackupChecked() { - stackNavigation.push(CreateWalletBackupRoute.BackupCompleted) + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.BackupCompleteScreen( + source = params.analyticsSource, + action = params.analyticsAction, + ), + ) + stackNavigation.push( + configuration = CreateWalletBackupRoute.BackupCompleted( + isUpgradeFlow = params.isUpgradeFlow, + isLastScreen = !params.shouldSetAccessCode, + ), + ) } fun onManualBackupCompleted() { - router.pop() + if (params.shouldSetAccessCode) { + router.replaceCurrent( + route = AppRoute.UpdateAccessCode( + userWalletId = params.userWalletId, + source = params.analyticsSource, + ), + ) + } else { + router.pop() + } } inner class ManualBackupStartModelCallbacks : ManualBackupStartComponent.ModelCallbacks { @@ -83,5 +137,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..7c76ba20f0 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,33 @@ 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, + isLastScreen = route.isLastScreen, ), ) } 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..74f503a15d 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,8 @@ internal sealed interface CreateWalletBackupRoute { data object ConfirmBackup : CreateWalletBackupRoute @Serializable - data object BackupCompleted : CreateWalletBackupRoute + data class BackupCompleted( + val isUpgradeFlow: Boolean, + val isLastScreen: 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..88fc9882cd --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt @@ -0,0 +1,112 @@ +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.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() { + uiMessageSender.send( + DialogMessage( + title = resourceReference(R.string.common_attention), + message = resourceReference(R.string.hw_remove_wallet_confirmation_title), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_forget), + isWarning = true, + onClick = ::forgetWallet, + ) + }, + secondActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_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.popTo(AppRoute.Details::class) + } 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..5e4d2f42ec --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt @@ -0,0 +1,154 @@ +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.extensions.stringResourceSafe +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) { + 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 = stringResourceSafe(R.string.common_attention), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResourceSafe(R.string.hw_remove_wallet_attention_description), + 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 = stringResourceSafe(R.string.hw_remove_wallet_warning_device), + ) + Spacer(modifier = Modifier.height(16.dp)) + CheckboxItem( + modifier = Modifier.padding(horizontal = 16.dp), + checked = state.secondCheckboxChecked, + onCheckedChange = state.onSecondCheckboxClick, + text = stringResourceSafe(R.string.hw_remove_wallet_warning_access), + ) + Spacer(modifier = Modifier.height(32.dp)) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = stringResourceSafe(R.string.hw_remove_wallet_action_forget_title), + onClick = state.onForgetWalletClick, + enabled = state.isForgetButtonEnabled, + ) + } +} + +@Composable +private fun CheckboxItem(checked: Boolean, onCheckedChange: () -> Unit, text: String, modifier: Modifier = Modifier) { + 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..7e44b3d501 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,14 @@ 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 isImportFlow: Boolean = false, + val isUpgradeFlow: Boolean = false, + val isLastScreen: Boolean = false, ) } \ 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..cc143ececd 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 @@ -3,12 +3,15 @@ package com.tangem.features.hotwallet.manualbackup.completed import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.hotwallet.manualbackup.completed.entity.ManualBackupCompletedUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider 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 +23,25 @@ internal class ManualBackupCompletedModel @Inject constructor( internal val uiState: StateFlow field = MutableStateFlow( ManualBackupCompletedUM( - onContinueClick = { params.callbacks.onContinueClick(params.userWalletId) }, + continueButtonText = if (params.isLastScreen) { + resourceReference(R.string.common_finish) + } else { + resourceReference(R.string.common_continue) + }, + onContinueClick = { + if (params.isUpgradeFlow) { + params.callbacks.onUpgradeClick(params.userWalletId) + } else { + params.callbacks.onContinueClick(params.userWalletId) + } + }, + title = if (params.isImportFlow) { + resourceReference(R.string.wallet_import_success_title) + } else { + resourceReference(R.string.backup_complete_title) + }, + description = resourceReference(R.string.backup_complete_description), + 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..093c0a036f 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 @@ -1,5 +1,11 @@ package com.tangem.features.hotwallet.manualbackup.completed.entity +import com.tangem.core.ui.extensions.TextReference + internal data class ManualBackupCompletedUM( + val title: TextReference, + val description: TextReference, + val continueButtonText: TextReference, 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..37cb7f0e8d 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 @@ -16,6 +16,8 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.features.hotwallet.manualbackup.completed.entity.ManualBackupCompletedUM @@ -43,7 +45,7 @@ internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifi top = 20.dp, end = 48.dp, ), - text = stringResourceSafe(R.string.backup_complete_title), + text = state.title.resolveReference(), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, @@ -64,9 +66,7 @@ internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifi Spacer(modifier = Modifier.weight(2f)) PrimaryButton( modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(R.string.common_continue), - showProgress = false, - enabled = true, + text = state.continueButtonText.resolveReference(), onClick = state.onContinueClick, ) } @@ -75,11 +75,32 @@ 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 PreviewManualBackupCompletedContentBackup() { TangemThemePreview { ManualBackupCompletedContent( state = ManualBackupCompletedUM( - onContinueClick = {}, + isLoading = false, + title = resourceReference(R.string.backup_complete_title), + continueButtonText = resourceReference(R.string.common_finish), + description = resourceReference(R.string.backup_complete_description), + onContinueClick = { }, + ), + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewManualBackupCompletedContentImport() { + TangemThemePreview { + ManualBackupCompletedContent( + state = ManualBackupCompletedUM( + isLoading = false, + title = resourceReference(R.string.wallet_import_success_title), + continueButtonText = resourceReference(R.string.common_continue), + description = resourceReference(R.string.backup_complete_description), + 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/setupfinished/MobileWalletSetupFinishedComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/MobileWalletSetupFinishedComponent.kt index 21756c5932..73904619cc 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/MobileWalletSetupFinishedComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/MobileWalletSetupFinishedComponent.kt @@ -27,7 +27,7 @@ internal class MobileWalletSetupFinishedComponent @AssistedInject constructor( } interface ModelCallbacks { - fun onContinueClick() + fun onFinishClick() } data class Params( diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/MobileWalletSetupFinishedModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/MobileWalletSetupFinishedModel.kt index d94e79c3ce..4785e8f5c9 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/MobileWalletSetupFinishedModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/MobileWalletSetupFinishedModel.kt @@ -20,7 +20,7 @@ internal class MobileWalletSetupFinishedModel @Inject constructor( internal val uiState: StateFlow field = MutableStateFlow( MobileWalletSetupFinishedUM( - onContinueClick = params.callbacks::onContinueClick, + onFinishClick = params.callbacks::onFinishClick, ), ) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/entity/MobileWalletSetupFinishedUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/entity/MobileWalletSetupFinishedUM.kt index f33462eaf9..6544116b56 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/entity/MobileWalletSetupFinishedUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/entity/MobileWalletSetupFinishedUM.kt @@ -1,5 +1,5 @@ package com.tangem.features.hotwallet.setupfinished.entity internal data class MobileWalletSetupFinishedUM( - val onContinueClick: () -> Unit, + val onFinishClick: () -> Unit, ) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/ui/MobileWalletSetupFinishedContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/ui/MobileWalletSetupFinishedContent.kt index 312e9b93e2..b7ede36d29 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/ui/MobileWalletSetupFinishedContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setupfinished/ui/MobileWalletSetupFinishedContent.kt @@ -70,7 +70,7 @@ internal fun MobileWalletSetupFinishedContent(state: MobileWalletSetupFinishedUM top = 12.dp, end = 48.dp, ), - text = stringResourceSafe(R.string.backup_complete_description), + text = stringResourceSafe(R.string.onboarding_done_wallet), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, @@ -78,10 +78,10 @@ internal fun MobileWalletSetupFinishedContent(state: MobileWalletSetupFinishedUM Spacer(modifier = Modifier.weight(2f)) PrimaryButton( modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(R.string.common_continue), + text = stringResourceSafe(R.string.common_finish), showProgress = false, enabled = true, - onClick = state.onContinueClick, + onClick = state.onFinishClick, ) } @@ -112,7 +112,7 @@ private fun PreviewMobileWalletSetupFinishedContent() { TangemThemePreview { MobileWalletSetupFinishedContent( state = MobileWalletSetupFinishedUM( - onContinueClick = {}, + onFinishClick = {}, ), ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/DefaultUpdateAccessCodeComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/DefaultUpdateAccessCodeComponent.kt index 127f506d0c..790f91c922 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/DefaultUpdateAccessCodeComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/DefaultUpdateAccessCodeComponent.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.value.ObserveLifecycleMode +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.model.getOrCreateModel @@ -14,6 +16,7 @@ import com.tangem.features.hotwallet.updateaccesscode.routing.UpdateAccessCodeCh import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch internal class DefaultUpdateAccessCodeComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @@ -38,6 +41,17 @@ internal class DefaultUpdateAccessCodeComponent @AssistedInject constructor( }, ) + init { + innerStack.subscribe( + lifecycle = lifecycle, + mode = ObserveLifecycleMode.CREATE_DESTROY, + ) { stack -> + componentScope.launch { + model.currentRoute.emit(stack.active.configuration) + } + } + } + @Composable override fun Content(modifier: Modifier) { val stackState by innerStack.subscribeAsState() diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeContent.kt index e8f3c4a40d..5ff0f472db 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeContent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.stack.animation.slide @@ -31,11 +32,19 @@ internal fun SetAccessCodeContent( .imePadding() .systemBarsPadding(), ) { - TangemTopAppBar( - modifier = Modifier, - title = stringResourceSafe(R.string.access_code_navtitle), - startButton = TopAppBarButtonUM.Back(onBackClick), - ) + if (stackState.active.configuration is UpdateAccessCodeRoute.SetupFinished) { + TangemTopAppBar( + modifier = Modifier, + title = stringResourceSafe(R.string.access_code_navtitle), + titleAlignment = Alignment.CenterHorizontally, + ) + } else { + TangemTopAppBar( + modifier = Modifier, + title = stringResourceSafe(R.string.access_code_navtitle), + startButton = TopAppBarButtonUM.Back(onBackClick), + ) + } Children( stack = stackState, animation = stackAnimation(slide()), diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt index 5cf4e05649..cd562b219b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/UpdateAccessCodeModel.kt @@ -11,6 +11,9 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.hotwallet.UpdateAccessCodeComponent import com.tangem.features.hotwallet.updateaccesscode.routing.UpdateAccessCodeRoute import com.tangem.features.hotwallet.accesscode.AccessCodeComponent +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents +import com.tangem.features.hotwallet.setupfinished.MobileWalletSetupFinishedComponent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject @@ -20,6 +23,7 @@ internal class UpdateAccessCodeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, paramsContainer: ParamsContainer, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model(), AccessCodeComponent.ModelCallbacks { private val params = paramsContainer.require() @@ -27,19 +31,32 @@ internal class UpdateAccessCodeModel @Inject constructor( val stackNavigation = StackNavigation() val startRoute: UpdateAccessCodeRoute = UpdateAccessCodeRoute.SetAccessCode(params.userWalletId) val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + val mobileWalletSetupFinishedComponentModelCallbacks = MobileWalletSetupFinishedComponentModelCallbacks() + + init { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.AccessCodeScreenOpened(source = params.source)) + } fun onChildBack() { when (currentRoute.value) { is UpdateAccessCodeRoute.SetAccessCode -> router.pop() is UpdateAccessCodeRoute.ConfirmAccessCode -> stackNavigation.pop() + is UpdateAccessCodeRoute.SetupFinished -> Unit } } override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ReEnterAccessCodeScreen(source = params.source)) stackNavigation.push(UpdateAccessCodeRoute.ConfirmAccessCode(userWalletId, accessCode)) } override fun onAccessCodeUpdated(userWalletId: UserWalletId) { - router.pop() + stackNavigation.push(UpdateAccessCodeRoute.SetupFinished) + } + + inner class MobileWalletSetupFinishedComponentModelCallbacks : MobileWalletSetupFinishedComponent.ModelCallbacks { + override fun onFinishClick() { + router.pop() + } } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt index 968560b8b3..cad1c55a0e 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeChildFactory.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.hotwallet.updateaccesscode.UpdateAccessCodeModel import com.tangem.features.hotwallet.accesscode.AccessCodeComponent +import com.tangem.features.hotwallet.setupfinished.MobileWalletSetupFinishedComponent import javax.inject.Inject internal class UpdateAccessCodeChildFactory @Inject constructor( @@ -31,6 +32,12 @@ internal class UpdateAccessCodeChildFactory @Inject constructor( callbacks = model, ), ) + is UpdateAccessCodeRoute.SetupFinished -> MobileWalletSetupFinishedComponent( + context = childContext, + params = MobileWalletSetupFinishedComponent.Params( + callbacks = model.mobileWalletSetupFinishedComponentModelCallbacks, + ), + ) } } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt index 31d9113158..75c71e79fb 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/updateaccesscode/routing/UpdateAccessCodeRoute.kt @@ -16,4 +16,7 @@ internal sealed class UpdateAccessCodeRoute : Route { val userWalletId: UserWalletId, val accessCode: String, ) : UpdateAccessCodeRoute() + + @Serializable + data object SetupFinished : UpdateAccessCodeRoute() } \ No newline at end of file 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..976804e17d 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 @@ -3,8 +3,10 @@ package com.tangem.features.hotwallet.upgradewallet import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.common.doOnFailure +import com.tangem.common.doOnResult import com.tangem.common.doOnSuccess import com.tangem.common.routing.AppRoute +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 @@ -16,20 +18,27 @@ 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.common.util.cardTypesResolver 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.analytics.WalletSettingsAnalyticEvents +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 +59,26 @@ internal class UpgradeWalletModel @Inject constructor( private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, private val tangemSdkManager: TangemSdkManager, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + private val analyticsEventHandler: AnalyticsEventHandler, ) : 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, + ), + ) + + init { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.HardwareUpgradeScreenOpened) + } override fun onDestroy() { clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) @@ -73,7 +91,8 @@ internal class UpgradeWalletModel @Inject constructor( } } - private fun onScanDeviceClick() { + private fun onContinueClick() { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonStartUpgrade) scanCard() } @@ -88,26 +107,58 @@ internal class UpgradeWalletModel @Inject constructor( tangemSdkManager .scanProduct() - .doOnSuccess { - delay(DELAY_SDK_DIALOG_CLOSE) - tangemSdkManager.changeDisplayedCardIdNumbersCount(it) - navigateToUpgradeFlow(it) + .doOnSuccess { scanResponse -> + checkIsWalletSuitableToBeUsedAsUpgrade(scanResponse = scanResponse) { + delay(DELAY_SDK_DIALOG_CLOSE) + tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse) + navigateToUpgradeFlow(scanResponse) + } } .doOnFailure { showCardVerificationFailedDialog(it) } + .doOnResult { + setLoading(false) + } } } private fun setLoading(isLoading: Boolean) { - _uiState.update { it.copy(isLoading = isLoading) } + uiState.update { it.copy(isLoading = isLoading) } + } + + private suspend fun checkIsWalletSuitableToBeUsedAsUpgrade( + scanResponse: ScanResponse, + onSuccess: suspend () -> Unit, + ) { + // Check if user attempted to upgrade before but something went wrong and a full reset is required + val userWallet = coldUserWalletBuilderFactory.create(scanResponse).build() + val isSameWalletButNotFinishedBackup = userWallet?.walletId == params.userWalletId && + BackupValidator.isValidFull(scanResponse.card).not() + + if (userWallet != null && isSameWalletButNotFinishedBackup) { + startResetCardsFlow.emit(userWallet) + return + } + + // Check for the correct card type + when { + scanResponse.card.wallets.isNotEmpty() -> uiMessageSender.send( + DialogMessage(resourceReference(R.string.hw_upgrade_error_card_already_has_wallet)), + ) + scanResponse.cardTypesResolver.isWallet2().not() -> uiMessageSender.send( + DialogMessage(resourceReference(R.string.hw_upgrade_error_wallet2_card_required)), + ) + scanResponse.card.settings.isKeysImportAllowed.not() -> uiMessageSender.send( + DialogMessage(resourceReference(R.string.hw_upgrade_error_card_key_import)), + ) + else -> onSuccess() + } } private fun showCardVerificationFailedDialog(error: TangemError) { if (error !is TangemSdkError.CardVerificationFailed) return - // TODO [REDACTED_TASK_KEY] track error - val resource = error.localizedDescriptionRes() val resId = resource.resId ?: R.string.common_unknown_error val resArgs = resource.args.map { it.value } @@ -143,4 +194,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/viewphrase/DefaultViewPhraseComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt index d82fbd968e..db4d664089 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/viewphrase/DefaultViewPhraseComponent.kt @@ -6,6 +6,7 @@ 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.security.DisableScreenshotsDisposableEffect import com.tangem.features.hotwallet.ViewPhraseComponent import com.tangem.features.hotwallet.viewphrase.model.ViewPhraseModel import com.tangem.features.hotwallet.viewphrase.ui.ViewPhraseContent @@ -22,6 +23,9 @@ internal class DefaultViewPhraseComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + + DisableScreenshotsDisposableEffect() + ViewPhraseContent( state = state, modifier = modifier, 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..d8f16f65e8 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,9 @@ 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.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -16,6 +19,8 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.ShouldAskPermissionUseCase +import com.tangem.domain.hotwallet.SetAccessCodeSkippedUseCase +import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent @@ -32,13 +37,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class WalletActivationModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + private val setAccessCodeSkippedUseCase: SetAccessCodeSkippedUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val trackingContextProxy: TrackingContextProxy, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { val params = paramsContainer.require() @@ -52,17 +61,44 @@ internal class WalletActivationModel @Inject constructor( val pushNotificationsCallbacks = PushNotificationsCallbacks() val mobileWalletSetupFinishedModelCallbacks = MobileWalletSetupFinishedModelCallbacks() + val isStartingWithAccessCode = params.isBackupExists val stackNavigation = StackNavigation() - val startRoute = WalletActivationRoute.ManualBackupStart + val startRoute = if (isStartingWithAccessCode) { + WalletActivationRoute.SetAccessCode + } else { + WalletActivationRoute.ManualBackupStart + } val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + private val source = AnalyticsParam.ScreensSources.Main + private val action = WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction.Backup + + init { + trackingContextProxy.addHotWalletContext() + if (startRoute is WalletActivationRoute.ManualBackupStart) { + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.RecoveryPhraseScreenInfo( + source = source.value, + action = action.value, + ), + ) + } + } + + override fun onDestroy() { + super.onDestroy() + trackingContextProxy.removeContext() + } + fun onChildBack() { when (currentRoute.value) { is WalletActivationRoute.ManualBackupStart -> router.pop() is WalletActivationRoute.ManualBackupPhrase -> stackNavigation.pop() is WalletActivationRoute.ManualBackupCheck -> stackNavigation.pop() is WalletActivationRoute.ManualBackupCompleted -> Unit - is WalletActivationRoute.SetAccessCode -> Unit + is WalletActivationRoute.SetAccessCode -> if (isStartingWithAccessCode) { + router.pop() + } is WalletActivationRoute.ConfirmAccessCode -> stackNavigation.pop() is WalletActivationRoute.PushNotifications -> Unit is WalletActivationRoute.SetupFinished -> Unit @@ -85,6 +121,8 @@ internal class WalletActivationModel @Inject constructor( } private fun showSkipAccessCodeWarningDialog() { + val userWalletId = params.userWalletId + uiMessageSender.send( DialogMessage( message = resourceReference(R.string.access_code_alert_skip_description), @@ -95,7 +133,12 @@ internal class WalletActivationModel @Inject constructor( ), secondAction = EventMessageAction( title = resourceReference(R.string.access_code_alert_skip_ok), - onClick = { navigateToPushNotificationsOrNext() }, + onClick = { + modelScope.launch { + setAccessCodeSkippedUseCase(userWalletId, true) + } + navigateToPushNotificationsOrNext() + }, ), shouldDismissOnFirstAction = true, ), @@ -115,13 +158,23 @@ internal class WalletActivationModel @Inject constructor( inner class ManualBackupStartModelCallbacks : ManualBackupStartComponent.ModelCallbacks { override fun onContinueClick() { stackNavigation.push(WalletActivationRoute.ManualBackupPhrase) + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.RecoveryPhraseScreen( + source = source.value, + action = action.value, + ), + ) } } inner class ManualBackupPhraseModelCallbacks : ManualBackupPhraseComponent.ModelCallbacks { override fun onContinueClick() { - stackNavigation.push( - WalletActivationRoute.ManualBackupCheck, + stackNavigation.push(WalletActivationRoute.ManualBackupCheck) + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.RecoveryPhraseCheck( + source = source.value, + action = action.value, + ), ) } } @@ -129,6 +182,12 @@ internal class WalletActivationModel @Inject constructor( inner class ManualBackupCheckModelCallbacks : ManualBackupCheckComponent.ModelCallbacks { override fun onCompleteClick() { stackNavigation.push(WalletActivationRoute.ManualBackupCompleted) + analyticsEventHandler.send( + event = WalletSettingsAnalyticEvents.BackupCompleteScreen( + source = source.value, + action = action.value, + ), + ) } } @@ -136,6 +195,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 { @@ -163,7 +224,7 @@ internal class WalletActivationModel @Inject constructor( } inner class MobileWalletSetupFinishedModelCallbacks : MobileWalletSetupFinishedComponent.ModelCallbacks { - override fun onContinueClick() { + override fun onFinishClick() { router.pop() } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationStepperStateManager.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationStepperStateManager.kt index 5a33b36b87..7c3056c55c 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationStepperStateManager.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationStepperStateManager.kt @@ -55,7 +55,7 @@ internal class WalletActivationStepperStateManager @Inject constructor() { steps = STEPS_COUNT, title = resourceReference(R.string.access_code_navtitle), showBackButton = true, - showSkipButton = false, + showSkipButton = true, showFeedbackButton = false, ) is WalletActivationRoute.PushNotifications -> HotWalletStepperComponent.StepperUM( 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..9afce42e0d 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 @@ -4,9 +4,11 @@ import com.tangem.core.ui.components.label.entity.LabelUM internal data class WalletBackupUM( val onBackClick: () -> Unit, + val hardwareWalletOption: LabelUM?, 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..1e183bf849 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,21 @@ package com.tangem.features.hotwallet.walletbackup.model +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.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.analytics.WalletSettingsAnalyticEvents +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,13 +26,17 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class WalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, - override val dispatchers: CoroutineDispatcherProvider, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, private val router: Router, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params: WalletBackupComponent.Params = paramsContainer.require() @@ -36,6 +45,10 @@ internal class WalletBackupModel @Inject constructor( field = MutableStateFlow( WalletBackupUM( onBackClick = { router.pop() }, + hardwareWalletOption = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, @@ -45,6 +58,7 @@ internal class WalletBackupModel @Inject constructor( style = LabelStyle.REGULAR, ), googleDriveStatus = BackupStatus.ComingSoon, + onBuyClick = ::onBuyClick, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, onHardwareWalletClick = ::onHardwareWalletClick, @@ -53,6 +67,7 @@ internal class WalletBackupModel @Inject constructor( ) init { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.BackupScreenOpened(isManualBackupEnabled = true)) getUserWalletUseCase.invokeFlow(params.userWalletId) .onEach { either -> either.fold( @@ -95,7 +110,14 @@ internal class WalletBackupModel @Inject constructor( backedUp = userWallet.backedUp, ) + private fun onBuyClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + private fun onRecoveryPhraseClick() { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonRecoveryPhrase) if (uiState.value.backedUp) { getUserWalletUseCase.invoke(params.userWalletId) .fold( @@ -113,7 +135,13 @@ internal class WalletBackupModel @Inject constructor( }, ) } else { - router.push(AppRoute.CreateWalletBackup(params.userWalletId)) + router.push( + AppRoute.CreateWalletBackup( + userWalletId = params.userWalletId, + analyticsSource = AnalyticsParam.ScreensSources.Backup.value, + analyticsAction = WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction.Backup.value, + ), + ) } } @@ -132,6 +160,7 @@ internal class WalletBackupModel @Inject constructor( } private fun onHardwareWalletClick() { - router.push(AppRoute.UpgradeWallet(params.userWalletId)) + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonHardwareUpdate) + 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..d3e452bab2 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,42 @@ 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 = { + state.hardwareWalletOption?.let { Label(it) } + }, + 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 +99,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 +111,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 @@ -109,6 +242,10 @@ private fun WalletBackupContentPreview(@PreviewParameter(WalletBackupUMProvider: private class WalletBackupUMProvider : CollectionPreviewParameterProvider( collection = listOf( WalletBackupUM( + hardwareWalletOption = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, @@ -119,12 +256,17 @@ 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..f7e8fff31f --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt @@ -0,0 +1,171 @@ +package com.tangem.features.hotwallet.wallethardwarebackup.model + +import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.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.message.bottomSheetMessage +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase +import com.tangem.features.hotwallet.WalletHardwareBackupComponent +import com.tangem.features.hotwallet.impl.R +import com.tangem.features.hotwallet.wallethardwarebackup.entity.WalletHardwareBackupUM +import com.tangem.hot.sdk.model.HotWalletId +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 timber.log.Timber +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 unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, + private val urlOpener: UrlOpener, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + private val params = paramsContainer.require() + + private val makeBackupAtFirstAlertBS + get() = run { + analyticsEventHandler.send( + WalletSettingsAnalyticEvents.NoticeBackupFirst( + source = AnalyticsParam.ScreensSources.HardwareWallet.value, + action = WalletSettingsAnalyticEvents.NoticeBackupFirst.Action.Upgrade, + ), + ) + bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_passcode_lock_32) { + type = MessageBottomSheetUMV2.Icon.Type.Accent + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = resourceReference(R.string.hw_backup_need_finish_first) + body = resourceReference(R.string.hw_backup_to_upgrade_description) + } + primaryButton { + text = resourceReference(R.string.hw_backup_need_action) + onClick { + router.push( + AppRoute.CreateWalletBackup( + userWalletId = params.userWalletId, + isUpgradeFlow = true, + analyticsSource = AnalyticsParam.ScreensSources.HardwareWallet.value, + analyticsAction = WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction.Upgrade.value, + ), + ) + closeBs() + } + } + } + } + + internal val uiState: StateFlow + field = MutableStateFlow( + WalletHardwareBackupUM( + onBackClick = { router.pop() }, + blocks = persistentListOf( + WalletHardwareBackupUM.Block( + title = resourceReference(R.string.hw_backup_hardware_create_title), + titleLabel = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), + description = resourceReference(R.string.hw_backup_hardware_create_description), + onClick = ::onCreateNewWalletClick, + ), + WalletHardwareBackupUM.Block( + title = resourceReference(R.string.hw_backup_hardware_upgrade_title), + titleLabel = null, + description = resourceReference(R.string.hw_backup_hardware_upgrade_description), + onClick = ::onUpgradeCurrentWalletClick, + ), + ), + onBuyClick = ::onBuyClick, + ), + ) + + init { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.HardwareBackupScreenOpened) + showPurchaseBlockWithDelay() + } + + private fun showPurchaseBlockWithDelay() { + modelScope.launch { + delay(SHOW_PURCHASE_BLOCK_DELAY) + uiState.update { it.copy(showPurchaseBlock = true) } + } + } + + private fun onCreateNewWalletClick() { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonCreateNewWallet) + 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) { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonUpgradeCurrent) + if (!userWallet.backedUp) { + messageSender.send(makeBackupAtFirstAlertBS) + } else { + val hotWalletId = userWallet.hotWalletId + when (hotWalletId.authType) { + HotWalletId.AuthType.NoPassword -> { + router.push(AppRoute.UpgradeWallet(userWalletId = params.userWalletId)) + } + HotWalletId.AuthType.Password, + HotWalletId.AuthType.Biometry, + -> modelScope.launch { + unlockHotWalletContextualUseCase.invoke(hotWalletId) + .onLeft { + Timber.e(it, "Unable to unlock wallet with id ${params.userWalletId}") + } + .onRight { + 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/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt b/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt index 97e2d1f0cc..dcae30ff3b 100644 --- a/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt +++ b/features/kyc/api/src/main/kotlin/com/tangem/features/kyc/KycComponent.kt @@ -2,10 +2,11 @@ package com.tangem.features.kyc import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId interface KycComponent : ComposableContentComponent { - data object Params + data class Params(val userWalletId: UserWalletId) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt index 76713a61b2..ba9a4604c5 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt @@ -27,7 +27,7 @@ class DefaultKycComponent @AssistedInject constructor( init { componentScope.launch { model.uiState.drop(1).collectLatest { startInfo -> - startInfo?.let { launchSdk(startInfo) } + if (startInfo != null) launchSdk(startInfo) router.pop() } } diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt index 7df782304f..6b44954fab 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt @@ -3,27 +3,36 @@ package com.tangem.features.kyc 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.KycStartInfo import com.tangem.domain.pay.repository.KycRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @Stable @ModelScoped class DefaultKycModel @Inject constructor( + paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val kycRepository: KycRepository, ) : Model() { + private val params: KycComponent.Params = paramsContainer.require() + private val _uiState: MutableStateFlow = MutableStateFlow(null) val uiState = _uiState.asStateFlow() init { modelScope.launch { - kycRepository.getKycStartInfo().getOrNull()?.let { _uiState.emit(it) } + try { + kycRepository.getKycStartInfo(params.userWalletId).getOrNull()?.let { _uiState.emit(it) } + } catch (e: Exception) { + Timber.e(e) + } } } } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt index c45a8667f6..e58aa8d6b4 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt @@ -2,6 +2,7 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.account.Account interface AddCustomTokenComponent : ComposableBottomSheetComponent { @@ -9,7 +10,7 @@ interface AddCustomTokenComponent : ComposableBottomSheetComponent { val mode: AddCustomTokenMode, val source: ManageTokensSource, val onDismiss: () -> Unit, - val onCurrencyAdded: () -> Unit, + val onCurrencyAdded: (account: Account?) -> Unit, ) interface Factory : ComponentFactory diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt index bdd3cdbc10..3c5fa79e29 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt @@ -19,11 +19,10 @@ sealed interface ManageTokensMode { sealed interface AddCustomTokenMode { val userWalletId: UserWalletId - get() = when (this) { - is Account -> accountId.userWalletId - is Wallet -> userWalletId - } data class Wallet(override val userWalletId: UserWalletId) : AddCustomTokenMode - data class Account(val accountId: AccountId) : AddCustomTokenMode + + data class Account(val accountId: AccountId) : AddCustomTokenMode { + override val userWalletId: UserWalletId = accountId.userWalletId + } } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/analytics/CommonManageTokensAnalyticEvents.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/analytics/CommonManageTokensAnalyticEvents.kt index e6cffb244a..33960dc166 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/analytics/CommonManageTokensAnalyticEvents.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/analytics/CommonManageTokensAnalyticEvents.kt @@ -26,8 +26,8 @@ sealed class CommonManageTokensAnalyticEvents( event = "Token Searched", params = buildMap { put(CHOSEN_TOKEN, if (isTokenChosen) "Yes" else "No") - if (token != null) { put(TOKEN_PARAM, token) } - if (blockchain != null) { put(BLOCKCHAIN, blockchain) } + if (token != null) put(TOKEN_PARAM, token) + if (blockchain != null) put(BLOCKCHAIN, blockchain) }, ) } \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 58cffdfd57..6fc7f5115a 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.domain.notifications) // region Project - Libs + implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) // endregion diff --git a/features/manage-tokens/impl/detekt-baseline-debug.xml b/features/manage-tokens/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d580e5706e --- /dev/null +++ b/features/manage-tokens/impl/detekt-baseline-debug.xml @@ -0,0 +1,51 @@ + + + + + BooleanPropertyNaming:AddCustomTokenUM.kt$AddCustomTokenUM$val showBackButton: Boolean + BooleanPropertyNaming:CustomCurrencyValidator.kt$CustomCurrencyValidator.Status.Validated$val fillForm: Boolean + BooleanPropertyNaming:CustomTokenFormModel.kt$CustomTokenFormModel$val needColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke( userWalletId = params.mode.userWalletId, networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value), ) + BooleanPropertyNaming:ManageTokensListManager.kt$ManageTokensListManager$val loadUserTokensFromRemote = when (mode) { is ManageTokensMode.Wallet, is ManageTokensMode.Account, -> source == ManageTokensSource.ONBOARDING ManageTokensMode.None, -> false } + BooleanPropertyNaming:ManageTokensModel.kt$ManageTokensModel$val needToInteractWithColdWallet = useCasesFacade.needColdWalletInteraction(networks) + BooleanPropertyNaming:ManageTokensUM.kt$ManageTokensUM.ManageContent$val needToInteractWithColdWallet: Boolean + BooleanPropertyNaming:OnboardingManageTokensModel.kt$OnboardingManageTokensModel$val showTangemIcon = useCasesFacade.needColdWalletInteraction(network = network) + BooleanPropertyNaming:OnboardingManageTokensUM.kt$OnboardingManageTokensUM.ActionButtonConfig$abstract val showProgress: Boolean + BooleanPropertyNaming:OnboardingManageTokensUM.kt$OnboardingManageTokensUM.ActionButtonConfig.Continue$val showTangemIcon: Boolean + MaxChainedCallsOnSameLine:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$params.initialCurrency.network.id.rawId.value + MultilineLambdaItParameter:ChooseManagedTokenContent.kt${ add( CurrencyItemUM.Basic( id = ManagedCryptoCurrency.ID( value = "ID+$it", ), name = "Bitcoin", symbol = "BTC", icon = CurrencyIconState.Loading, networks = CurrencyItemUM.Basic.NetworksUM.Collapsed, onExpandClick = {}, ), ) } + MultilineLambdaItParameter:ChooseManagedTokensModel.kt$ChooseManagedTokensModel${ it.copy( notificationUM = null, ) } + MultilineLambdaItParameter:CurrencyItemMapper.kt${ it.toCurrencyNetworkModel( isSelected = it.network in addedIn, isEditable = false, onSelectedStateChange = { _, _ -> }, onLongTap = { _ -> }, ) } + MultilineLambdaItParameter:CurrencyNetworksMapper.kt${ it.toCurrencyNetworkModel( isSelected = it.network in addedIn, isEditable = isItemsEditable, onSelectedStateChange = onSelectedStateChange, onLongTap = onLongTap, ) } + MultilineLambdaItParameter:CustomCurrencyFormOperations.kt${ it[Field.CONTRACT_ADDRESS] = it.getValue(Field.CONTRACT_ADDRESS).copy( error = when (exception) { CustomTokenFormValidationException.ContractAddress.Invalid -> { resourceReference(R.string.custom_token_creation_error_invalid_contract_address) } }, ) } + MultilineLambdaItParameter:CustomCurrencyFormOperations.kt${ it[Field.DECIMALS] = it.getValue(Field.DECIMALS).copy( error = when (exception) { is CustomTokenFormValidationException.Decimals.Empty -> { null // Should not display this error } is CustomTokenFormValidationException.Decimals.Invalid -> { resourceReference( R.string.custom_token_creation_error_wrong_decimals, wrappedList(ValidateTokenFormUseCase.MAX_DECIMALS), ) } }, ) } + MultilineLambdaItParameter:CustomTokenFormContent.kt$PreviewCustomTokenFormComponentProvider${ it[Field.CONTRACT_ADDRESS] = it[Field.CONTRACT_ADDRESS]!!.copy( label = stringReference("Contract address"), value = "0x1234567890", error = stringReference("Contract address is invalid"), placeholder = stringReference("0x1234567890"), ) } + MultilineLambdaItParameter:CustomTokenFormContent.kt$PreviewCustomTokenFormComponentProvider${ it[Field.CONTRACT_ADDRESS] = it[Field.CONTRACT_ADDRESS]!!.copy( label = stringReference("Contract address"), value = "0x1234567890", placeholder = stringReference("0x1234567890"), ) } + MultilineLambdaItParameter:CustomTokenFormModel.kt$CustomTokenFormModel${ Timber.e(it, "Failed to add currency") showErrorDialog() return@resource } + MultilineLambdaItParameter:CustomTokenFormModel.kt$CustomTokenFormModel${ Timber.e(it, "Failed to derive public keys") showErrorDialog() return@resource } + MultilineLambdaItParameter:CustomTokenSelectorModel.kt$CustomTokenSelectorModel${ when (it) { is AccountStatus.CryptoPortfolio -> it.sameNodeAndNotMain() } } + MultilineLambdaItParameter:ManageTokensListManager.kt$ManageTokensListManager${ Timber.e( it, """ Failed to check currency unsupported state |- Mode: $mode |- Source Network: $sourceNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null } + MultilineLambdaItParameter:ManageTokensListManager.kt$ManageTokensListManager${ Timber.e( it, """ Failed to check linked tokens |- Mode: $mode |- Network ID: ${network.id} """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) false } + MultilineLambdaItParameter:ManageTokensModel.kt$ManageTokensModel${ Timber.e(it, "Failed to save changes") return@resource } + MultilineLambdaItParameter:ManageTokensUseCasesFacade.kt$ManageTokensUseCasesFacade${ it is CryptoCurrency.Token && it.network.backendId == network.backendId && it.network.derivationPath == network.derivationPath } + MultilineLambdaItParameter:OnboardingManageTokensModel.kt$OnboardingManageTokensModel${ Timber.e(it, "Failed to save changes") return@resource } + MultilineLambdaItParameter:PreviewManageTokensComponent.kt$PreviewManageTokensComponent${ it.fastForEachIndexed { index, network -> if (index == networkIndex) { it[index] = network.copy( iconResId = if (isSelected) { R.drawable.img_eth_22 } else { R.drawable.ic_eth_16 }, isSelected = isSelected, ) } } } + NamedArguments:ChangedCurrenciesManager.kt$ChangedCurrenciesManager$updateChangedItems(currency, network, currenciesToAdd, currenciesToRemove) + NamedArguments:ChangedCurrenciesManager.kt$ChangedCurrenciesManager$updateChangedItems(currency, network, currenciesToRemove, currenciesToAdd) + NamedArguments:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$TokenSearched( params.analyticsCategoryName, token = null, blockchain = null, isTokenChosen = false, ) + NamedArguments:ManageTokensListManager.kt$ManageTokensListManager$selectNetwork(currencyBatch.key, currency, networkId, isSelected) + NamedArguments:ManageTokensListManager.kt$ManageTokensListManager$sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = false) + NamedArguments:ManageTokensListManager.kt$ManageTokensListManager$sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = true) + NestedScopeFunctions:CustomTokenSelectorModel.kt$CustomTokenSelectorModel$let { recognizer.recognize(it) } + NullableBooleanCheck:CustomCurrencyFormOperations.kt$this.tokenForm?.wasFilled ?: false + PropertyUsedBeforeDeclaration:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$bottomSheetNavigation + PropertyUsedBeforeDeclaration:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$uiState + UnsafeCallOnNullableType:CustomTokenFormContent.kt$PreviewCustomTokenFormComponentProvider$it[Field.CONTRACT_ADDRESS]!! + UnsafeCallOnNullableType:PreviewAddCustomTokenComponent.kt$PreviewAddCustomTokenComponent$config.selectedDerivationPath!! + UnsafeCallOnNullableType:PreviewAddCustomTokenComponent.kt$PreviewAddCustomTokenComponent$config.selectedNetwork!! + UseEmptyCounterpart:CustomTokenAnalyticsEvent.kt$CustomTokenAnalyticsEvent$mapOf() + UseEmptyCounterpart:ManageTokensAnalyticEvent.kt$ManageTokensAnalyticEvent$mapOf() + UseOrEmpty:ChangedCurrenciesManager.kt$ChangedCurrenciesManager$items[currency] ?: emptySet() + UseOrEmpty:PreviewCustomTokenSelectorComponent.kt$PreviewCustomTokenSelectorComponent$d.id?.rawId?.value ?: "" + VarCouldBeVal:CustomTokenFormModel.kt$CustomTokenFormModel$private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode) + + diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt index 56f2b08ca9..d4bac9bb7a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenDerivationInputComponent.kt @@ -3,11 +3,13 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableDialogComponent import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath +import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork internal interface CustomTokenDerivationInputComponent : ComposableDialogComponent { data class Params( val mode: AddCustomTokenMode, + val selectedNetwork: SelectedNetwork, val onConfirm: (SelectedDerivationPath) -> Unit, val onDismiss: () -> Unit, ) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt index a4ec53c9d9..f23d966cf2 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenSelectorComponent.kt @@ -2,6 +2,7 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.account.Account import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork @@ -19,7 +20,7 @@ internal interface CustomTokenSelectorComponent : ComposableContentComponent { val mode: AddCustomTokenMode, val selectedNetwork: SelectedNetwork, val selectedDerivationPath: SelectedDerivationPath?, - val onDerivationPathSelected: (SelectedDerivationPath) -> Unit, + val onDerivationPathSelected: (SelectedDerivationPath, Account?) -> Unit, ) : Params() } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt index 1951b3cd44..8e270519a8 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt @@ -14,6 +14,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.component.AddCustomTokenComponent @@ -42,6 +43,8 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR, ) + private var addedToAccount: Account? = null + private val navigation = StackNavigation() private val contentStack = childStack( key = "add_custom_token_content_stack", @@ -57,6 +60,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( } override fun dismiss() { + addedToAccount = null params.onDismiss() } @@ -163,12 +167,13 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( showForm(network = network) } - private fun changeDerivationPath(derivationPath: SelectedDerivationPath) { + private fun changeDerivationPath(derivationPath: SelectedDerivationPath, account: Account?) { val event = CustomTokenAnalyticsEvent.DerivationSelected( derivationName = derivationPath.name, source = params.source, ) analyticsEventHandler.send(event) + addedToAccount = account showForm(derivationPath = derivationPath) } @@ -228,8 +233,8 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( } private fun dismissAndNotify() { + params.onCurrencyAdded(addedToAccount) dismiss() - params.onCurrencyAdded() } @AssistedFactory diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt index 8c093ac7d6..7a8ab034ac 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt @@ -108,7 +108,7 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr val value = state.value.value.text val model = SelectedDerivationPath( - id = null, + id = params.selectedNetwork.id, value = Network.DerivationPath.Custom(value), name = value, isDefault = false, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt index 44c781e3cc..423bfb6d36 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenSelectorComponent.kt @@ -42,6 +42,7 @@ internal class DefaultCustomTokenSelectorComponent @AssistedInject constructor( context = childByContext(context), params = CustomTokenDerivationInputComponent.Params( mode = config.mode, + selectedNetwork = config.network, onConfirm = model::selectCustomDerivationPath, onDismiss = model.dialogNavigation::dismiss, ), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt index 9dc5539ae4..bddf89e1cf 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt @@ -12,10 +12,16 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName import com.tangem.features.managetokens.component.AddCustomTokenComponent import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig +import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.model.ManageTokensModel import com.tangem.features.managetokens.ui.ManageTokensScreen import dagger.assisted.Assisted @@ -64,7 +70,20 @@ internal class DefaultManageTokensComponent @AssistedInject constructor( mode = mode, source = params.source, onDismiss = model.bottomSheetNavigation::dismiss, - onCurrencyAdded = model::reloadList, + onCurrencyAdded = { account -> + val accountName = account?.accountName as? AccountName.Custom + if (accountName != null && (account as? Account.CryptoPortfolio)?.isMainAccount == false) { + messageSender.send( + SnackbarMessage( + message = resourceReference( + R.string.custom_token_another_account_snackbar_text, + wrappedList(accountName.value), + ), + ), + ) + } + model.reloadList() + }, ), ) } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt index 239866349d..7427de4342 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt @@ -63,7 +63,7 @@ internal class PreviewAddCustomTokenComponent( mode = config.mode, selectedNetwork = config.selectedNetwork!!, selectedDerivationPath = config.selectedDerivationPath!!, - onDerivationPathSelected = {}, + onDerivationPathSelected = { _, _ -> }, ), ).Content(modifier) } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt index 9d4485dee8..71f9caee9a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -43,7 +43,7 @@ internal class PreviewCustomTokenSelectorComponent( value = d.value.value.orEmpty(), networkName = stringReference(d.name), isSelected = d.value == params.selectedDerivationPath?.value, - onSelectedStateChange = { params.onDerivationPathSelected(d) }, + onSelectedStateChange = { params.onDerivationPathSelected(d, null) }, ) } is Params.NetworkSelector -> { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt index fc34e8cdda..9d7685fb1d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/customtoken/CustomTokenSelectorDialogConfig.kt @@ -9,5 +9,6 @@ internal sealed class CustomTokenSelectorDialogConfig { @Serializable data class CustomDerivationInput( val mode: AddCustomTokenMode, + val network: SelectedNetwork, ) : CustomTokenSelectorDialogConfig() } \ 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/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index cadcf9f356..c57d03a4e8 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -18,6 +18,7 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.managetokens.GetSupportedNetworksUseCase +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.AddCustomTokenMode @@ -126,7 +127,7 @@ internal class CustomTokenSelectorModel @Inject constructor( isDefault = true, ) - selector.onDerivationPathSelected(model) + selector.onDerivationPathSelected(model, null) }, ) } @@ -151,7 +152,7 @@ internal class CustomTokenSelectorModel @Inject constructor( isDefault = false, ) - selector.onDerivationPathSelected(model) + selector.onDerivationPathSelected(model, null) }, ) } @@ -171,7 +172,10 @@ internal class CustomTokenSelectorModel @Inject constructor( private fun showCustomDerivationInput() { val config = when (params) { is NetworkSelector -> return - is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.mode) + is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput( + params.mode, + params.selectedNetwork, + ) } dialogNavigation.activate(config) @@ -183,14 +187,14 @@ internal class CustomTokenSelectorModel @Inject constructor( is DerivationPathSelector -> if (accountsFeatureToggles.isFeatureEnabled) { params.checkAccountDerivation(value) } else { - params.onDerivationPathSelected(value) + params.onDerivationPathSelected(value, null) } } } private fun DerivationPathSelector.checkAccountDerivation(derivationPath: SelectedDerivationPath) = modelScope.launch { - val accountName = derivationPath.id + val account = derivationPath.id ?.let { Blockchain.fromId(it.rawId.value) }?.let(::AccountNodeRecognizer) ?.let { recognizer -> derivationPath.value.value?.let { recognizer.recognize(it) } } ?.let { accountNode -> @@ -199,24 +203,27 @@ internal class CustomTokenSelectorModel @Inject constructor( val accounts = singleAccountStatusListSupplier(mode.userWalletId) .first().accountStatuses - val account = accounts.find { + + val accountStatus = accounts.find { when (it) { is AccountStatus.CryptoPortfolio -> it.sameNodeAndNotMain() } } - val accountName = when (account) { - is AccountStatus.CryptoPortfolio -> account.account.accountName.toUM() - null -> null - } - accountName + + accountStatus?.account } + val accountName = when (account) { + is Account.CryptoPortfolio -> account.accountName + null -> null + } + if (accountName == null) { - onDerivationPathSelected(derivationPath) + onDerivationPathSelected(derivationPath, null) } else { showAccountNameExist( - accountName = accountName.value, - onClick = { onDerivationPathSelected(derivationPath) }, + accountName = accountName.toUM().value, + onClick = { onDerivationPathSelected(derivationPath, account) }, ) } } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt index 7d7026d93c..ae218b4341 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt @@ -13,6 +13,8 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.models.account.AccountId import com.tangem.domain.redux.OnboardingManageTokensAction import com.tangem.domain.redux.ReduxStateHolder import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent @@ -43,13 +45,18 @@ internal class OnboardingManageTokensModel @Inject constructor( private val messageSender: UiMessageSender, private val reduxStateHolder: ReduxStateHolder, private val analyticsEventHandler: AnalyticsEventHandler, + accountsFeatureToggles: AccountsFeatureToggles, manageTokensListManagerFactory: ManageTokensListManager.Factory, manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, paramsContainer: ParamsContainer, ) : Model() { private val params: OnboardingManageTokensComponent.Params = paramsContainer.require() - private val portfolio = ManageTokensMode.Wallet(params.userWalletId) + private val portfolio = if (accountsFeatureToggles.isFeatureEnabled) { + ManageTokensMode.Account(accountId = AccountId.forMainCryptoPortfolio(params.userWalletId)) + } else { + ManageTokensMode.Wallet(params.userWalletId) + } private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory .create(mode = portfolio) private val manageTokensListManager = manageTokensListManagerFactory.create( diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt index 37097a8f34..b023409947 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt @@ -288,7 +288,7 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider : name = "", isDefault = false, ), - onDerivationPathSelected = {}, + onDerivationPathSelected = { _, _ -> }, ), ), PreviewCustomTokenSelectorComponent( 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..ca9d684abe 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,57 @@ package com.tangem.features.managetokens.utils.list import arrow.core.Either -import arrow.core.NonEmptyList +import arrow.core.raise.Raise +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull +import arrow.core.right +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.account.supplier.SingleAccountListSupplier 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.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.tokens.AddCryptoCurrenciesUseCase -import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.features.managetokens.component.AddCustomTokenMode +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import timber.log.Timber @Suppress("LongParameterList") internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( + @Assisted private val mode: AddCustomTokenMode, 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, - @Assisted private val mode: AddCustomTokenMode, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val singleAccountListSupplier: SingleAccountListSupplier, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, ) { - 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 -> either { + val accountId = getAccountId(currency) + + manageCryptoCurrenciesUseCase(accountId = accountId, add = currency).bind() + } + 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 +63,16 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( derivationPath: Network.DerivationPath, contractAddress: String?, ): Either = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Account -> { + getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = mode.accountId.userWalletId, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, + ) + .fold(ifEmpty = { true }, ifSome = { false }) + .right() + } is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke( userWalletId = mode.userWalletId, networkId = networkId, @@ -69,43 +81,54 @@ 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, + private suspend fun Raise.getAccountId(currency: CryptoCurrency): AccountId { + val accountList = singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = mode.userWalletId), ) + + ensureNotNull(accountList) { + IllegalStateException("Account list not found: ${mode.userWalletId}") + } + + if (accountList.activeAccounts == 1) { + return AccountId.forMainCryptoPortfolio(userWalletId = mode.userWalletId) + } + + val currencyAccountIndex = currency.getAccountIndex().bind() + + val account = accountList.accounts.firstOrNull { account -> + val cryptoPortfolioAccount = account as? Account.CryptoPortfolio + + cryptoPortfolioAccount?.derivationIndex?.value == currencyAccountIndex + } + + return account?.accountId ?: AccountId.forMainCryptoPortfolio(userWalletId = mode.userWalletId) } - 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, - ) - } + private fun CryptoCurrency.getAccountIndex(): Either = either { + val currency = this@getAccountIndex + val blockchain = Blockchain.fromNetworkId(networkId = currency.network.backendId) + if (blockchain == null) { + val exception = IllegalStateException("Token has unknown networkId: ${currency.id}") + Timber.e(exception) + raise(exception) + } - 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, - ) + val derivationPathValue = currency.network.derivationPath.value + if (derivationPathValue == null) { + val exception = IllegalStateException("Token has no derivation path: ${currency.id}") + Timber.e(exception) + raise(exception) + } + + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + val index = accountNodeRecognizer.recognize(derivationPathValue)?.toInt() + + ensureNotNull(index) { + val exception = IllegalStateException("Token has unrecognized derivation path: ${currency.id}") + Timber.e(exception) + exception + } } @AssistedFactory 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/detekt-baseline-debug.xml b/features/markets/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..58ef1132a3 --- /dev/null +++ b/features/markets/impl/detekt-baseline-debug.xml @@ -0,0 +1,161 @@ + + + + + BooleanPropertyNaming:AddToPortfolioBSContentUM.kt$AddToPortfolioBSContentUM$val continueButtonEnabled: Boolean + BooleanPropertyNaming:AvailableToAddData.kt$AvailableToAddData$val availableToAdd: Boolean get() = availableToAddWallets.isNotEmpty() + BooleanPropertyNaming:AvailableToAddData.kt$SelectedNetwork$val availableMoreNetwork: Boolean + BooleanPropertyNaming:AvailableToAddData.kt$SelectedPortfolio$val availableMorePortfolio: Boolean + BooleanPropertyNaming:DefaultAddToPortfolioComponent.kt$DefaultAddToPortfolioComponent$val scrollableContent = when (stack.active.configuration) { AddToPortfolioRoutes.PortfolioSelector -> false AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, is AddToPortfolioRoutes.NetworkSelector, AddToPortfolioRoutes.TokenActions, -> true } + BooleanPropertyNaming:MarketsList.kt$val mainScrolled by remember { derivedStateOf { mainLazyListState.firstVisibleItemScrollOffset > 0 } } + BooleanPropertyNaming:MarketsList.kt$val searchScrolledState by remember { derivedStateOf { searchLazyListState.firstVisibleItemScrollOffset > 0 } } + BooleanPropertyNaming:MarketsListAnalyticsEvent.kt$MarketsListAnalyticsEvent.TokenSearched$val tokenFound: Boolean + BooleanPropertyNaming:MarketsListUM.kt$ListUM.Content$val showUnder100kTokensNotification: Boolean + BooleanPropertyNaming:MarketsListUM.kt$ListUM.Content$val showUnder100kTokensNotificationWasHidden: Boolean + BooleanPropertyNaming:MarketsListUMStateManager.kt$MarketsListUMStateManager$private var sortByBottomSheetIsShown get() = state.value.sortByBottomSheet.isShown set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) } + BooleanPropertyNaming:MarketsTokenDetailsContent.kt$val showPriceSubtitle by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 1 } } + BooleanPropertyNaming:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$val allWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + BooleanPropertyNaming:MarketsTokenDetailsUM.kt$MarketsTokenDetailsUM$val markerSet: Boolean + BooleanPropertyNaming:MetricsBlock.kt$var expanded by remember { mutableStateOf(false) } + BooleanPropertyNaming:PortfolioBSVisibilityModel.kt$PortfolioBSVisibilityModel$val addToPortfolioBSVisibility: Boolean = false + BooleanPropertyNaming:PortfolioBSVisibilityModel.kt$PortfolioBSVisibilityModel$val walletSelectorBSVisibility: Boolean = false + BooleanPropertyNaming:PortfolioItem.kt$var quickActionsShown by remember { mutableStateOf(value = false) } + BooleanPropertyNaming:PortfolioUIData.kt$PortfolioUIData$val needColdWalletInteraction: Boolean + BooleanPropertyNaming:QuickActionUM.kt$QuickActionUM$val longClickAvailable: Boolean = false + BooleanPropertyNaming:QuickActionUM.kt$QuickActionUM.Exchange$val showBadge: Boolean + BooleanPropertyNaming:TokenActionsHandler.kt$TokenActionsHandler$val demoCard = isDemoCardUseCase.invoke(userWallet.cardId) + BooleanPropertyNaming:TokenActionsHandler.kt$TokenActionsHandler$val needShowDemoWarning = demoCard && disabledActionsInDemoMode.contains(action) + CanBeNonNullable:TokenMarketDetailsBody.kt$portfolioBlock: @Composable ((Modifier) -> Unit)? + CastNullableToNonNullableType:PortfolioItem.kt$PortfolioTokenUMProvider$as + CastNullableToNonNullableType:PortfolioItem.kt$PortfolioTokenUMProvider.Companion$as + MaxChainedCallsOnSameLine:AddToPortfolioModel.kt$AddToPortfolioModel$selectedPortfolio.first().account.account.account.accountId + MaxChainedCallsOnSameLine:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id } + MaxChainedCallsOnSameLine:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$batchFlow.state.value.data.map { it.key }.toSet() + MaxChainedCallsOnSameLine:MarketsListModel.kt$MarketsListModel$marketsListUMStateManager.state.map { it.selectedSortBy }.distinctUntilChanged().drop(1).collectLatest { mainMarketsListManager.reload() } + MaxChainedCallsOnSameLine:MarketsPortfolioModel.kt$MarketsPortfolioModel$handledAction.cryptoCurrencyData.status.currency.network.name + MaxChainedCallsOnSameLine:TokenActionsModel.kt$TokenActionsModel$handledAction.cryptoCurrencyData.status.currency.network.name + MultilineLambdaItParameter:AddToPortfolioBSContentUMFactory.kt$AddToPortfolioBSContentUMFactory${ if (it != selectedWalletId) { onAnotherWalletSelect(it) onWalletSelectorVisibilityChange(false) } } + MultilineLambdaItParameter:AddToPortfolioBottomSheet.kt${ Content( modifier = Modifier.fillMaxWidth(), state = it, ) WalletSelectorBottomSheet(it.walletSelectorConfig) } + MultilineLambdaItParameter:AddToPortfolioManager.kt$AddToPortfolioManager${ it.toMutableMap().apply { this[userWalletId] = if (isAddAction) { this[userWalletId].orEmpty() + network } else { this[userWalletId].orEmpty() - network } } } + MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ PortfolioData.CryptoCurrencyData( userWallet = selectedPortfolio.userWallet, status = addedToken, actions = it.states, ) } + MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ Timber.e(it) params.callback.onDismiss() } + MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ tokenActionsData.emit(it) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) } + MultilineLambdaItParameter:AddTokenModel.kt$AddTokenModel${ processError(error = it) uiState.value = um.toggleProgress(false) return@launch } + MultilineLambdaItParameter:AvailableToAddDataConverter.kt$AvailableToAddDataConverter${ val (walletId, _) = it val availableToAddWallet = getAvailableToAddWallet(it) walletId to availableToAddWallet } + MultilineLambdaItParameter:ChooseNetworkModel.kt$ChooseNetworkModel${ Timber.e( it, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId |- Network ID: $rawNetworkId |- Is main network: $isMainNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null } + MultilineLambdaItParameter:DefaultMarketsTokenDetailsComponent.kt$DefaultMarketsTokenDetailsComponent${ when (it) { is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(it.networks) TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() else -> {} } } + MultilineLambdaItParameter:DefaultMarketsTokenListComponent.kt$DefaultMarketsTokenListComponent${ MarketsListWithBack( modifier = Modifier .statusBarsPadding() .imePadding() .padding(it), state = state, bottomSheetState = BottomSheetState.EXPANDED, onBackClick = router::pop, ) } + MultilineLambdaItParameter:EntryBottomSheetContent.kt${ when (it.configuration) { is MarketsEntryChildFactory.Child.TokenDetails -> { (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) } is MarketsEntryChildFactory.Child.TokenList -> { (it.instance as MarketsTokenListComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) } } } + MultilineLambdaItParameter:InsightsBlock.kt${ InfoPoint( modifier = Modifier.align(Alignment.CenterStart), infoPointUM = it, ) } + MultilineLambdaItParameter:InsightsBlock.kt${ currentInterval = it state.onIntervalChanged(it) } + MultilineLambdaItParameter:LinksBlock.kt${ Chip( text = stringReference(it.title), iconResId = it.iconRes, onClick = { onLinkClick(it) }, ) } + MultilineLambdaItParameter:MarketTokenDetailsChart.kt${ when (it) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor MarketChartLook.Type.Neutral -> neutralColor } } + MultilineLambdaItParameter:MarketsList.kt${ if (it.size.height > 0) { with(density) { onHeaderSizeChange(it.size.height.toDp()) } } } + MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ Batch( key = it.key, data = converter.convertList(it.data), ) } + MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ currentSearchText().isNullOrEmpty().not() && it.status is PaginationStatus.EndOfPagination && it.data.isEmpty() } + MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ val first = it.data.firstOrNull() ?: return@filter false val chartByInterval = when (interval) { TrendInterval.H24 -> first.tokenCharts.h24 TrendInterval.D7 -> first.tokenCharts.week TrendInterval.M1 -> first.tokenCharts.month } chartByInterval != null } + MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ when (val status = it.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { it.data.lastOrNull()?.key } else { null } } is PaginationStatus.EndOfPagination -> { it.data.lastOrNull()?.key } else -> null } } + MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ when (val status = it.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { it.data.size == 1 } else { null } } is PaginationStatus.EndOfPagination -> { it.data.size == 1 } else -> null } } + MultilineLambdaItParameter:MarketsListItem.kt${ if (Random.nextBoolean()) { it.first.inc() to PriceChangeType.UP } else { it.first.dec() to PriceChangeType.DOWN } } + MultilineLambdaItParameter:MarketsListLazyColumn.kt${ (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() ?.let { rawId -> CryptoCurrency.RawID(rawId) } } + MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it == BottomSheetState.EXPANDED) { analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened) } } + MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it) { analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = false)) } } + MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.isNotEmpty()) { activeListManager.getBatchKeysByItemIds(visibleItemIds.value) } else { null } } + MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.list !is ListUM.Content) { visibleItemIds.value = emptyList() } } + MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } + MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } + MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( list = if (it.list is ListUM.Content) { it.list.copy( triggerScrollReset = consumedEvent(), ) } else { it.list }, ) } + MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( price = it.price.copy( changeType = if (visibleItemIds.contains(it.id)) { it.price.changeType } else { null }, ), ) } + MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( searchBar = it.searchBar.copy( query = value, isActive = value.isNotEmpty(), ), ) } + MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedInterval = value, list = if (it.list is ListUM.Content && it.selectedSortBy != SortByTypeUM.Rating && it.selectedInterval != value ) { it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) } else { it.list }, ) } + MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedSortBy = sortByTypeUM, sortByBottomSheet = it.sortByBottomSheet.copy( isShown = false, content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = sortByTypeUM, ), ), ) } + MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedSortBy = value, sortByBottomSheet = it.sortByBottomSheet.copy( content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = value, ), ), list = if (it.list is ListUM.Content && it.selectedSortBy != value) { it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) } else { it.list }, ) } + MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ when { isInErrorState -> { it.copy( list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked), ) } isSearchNotFound -> { it.copy(list = ListUM.SearchNothingFound) } uiItems.isEmpty() -> { it.copy(list = ListUM.Loading) } else -> { it.updateItems( newItems = uiItems, stakingNotificationMaxApy = stakingNotificationMaxApy, ) } } } + MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ Timber.e( it, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId |- Network ID: $rawNetworkId |- Is main network: $isMainNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null } + MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ onWalletSelect(it) // === Analytics === analyticsEventHandler.send( analyticsEventBuilder.addToPortfolioWalletChanged(), ) } + MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ when (it) { is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available NewAddToPortfolioManager.State.Init -> AddButtonState.Loading NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable } } + MultilineLambdaItParameter:MarketsTokenDetailsContent.kt${ if (it.size.height > 0) { with(density) { onHeaderSizeChange(it.size.height.toDp()) } } } + MultilineLambdaItParameter:MarketsTokenDetailsContent.kt${ val nextColor = when (it) { PriceChangeType.UP, -> growColor PriceChangeType.DOWN -> fallColor PriceChangeType.NEUTRAL -> return@EventEffect } color.animateTo(nextColor, snap()) color.animateTo(generalColor, tween(durationMillis = 500)) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ BlockchainUtils.isSupportedNetworkId( blockchainId = it.networkId, excludedBlockchains = excludedBlockchains, hotExcludedBlockchains = hotWalletExcludedBlockchains, hasOnlyHotWallets = allWalletsIsHot, ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ ExchangesBottomSheetContent.Content( exchangeItems = ExchangeItemStateConverter.convertList(it).toImmutableList(), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ MarketsDateTimeFormatters.formatDateByIntervalWithMarker( interval = currentState.selectedInterval, markerTimestamp = it, ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights, interval = it, ), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, interval = it, ), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ getChangePercentBetween( previousPrice = it, currentPrice = currentQuotes.value.currentPrice, ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { it.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { it.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( body = MarketsTokenDetailsUM.Body.Loading, ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.DATA, ), body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) { MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) } else { it.body }, ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), body = if (it.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { it.body }, ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( priceChangePercentText = priceChangePercent, selectedInterval = interval, priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() ?: PriceChangeType.NEUTRAL, dateTimeText = getDefaultDateTimeString(interval), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( priceText = newInfo.quotes.currentPrice.format { fiat( fiatCurrencySymbol = currentAppCurrency.value.symbol, fiatCurrencyCode = currentAppCurrency.value.code, ).price() }, priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval( interval = it.selectedInterval, ), priceChangeType = percent.percentChangeType(), body = MarketsTokenDetailsUM.Body.Content( description = descriptionConverter.convert(newInfo), infoBlocks = infoConverter.convert(newInfo), ), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( selectedInterval = interval, chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( type = percent.percentChangeType().toChartType(), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( xAxisFormatter = xAxisFormatter, type = state.value.priceChangeType.toChartType(), ) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.urlData?.fullUrl?.let { url -> urlOpener.openUrl(url) } // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(it.name)) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ showBottomSheet(it) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked()) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ showBottomSheet(it) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened()) } + MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() it.copy( type = percentChangeType.toChartType(), xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), yAxisFormatter = { value -> value.format { fiat( fiatCurrencyCode = currentAppCurrency.value.code, fiatCurrencySymbol = currentAppCurrency.value.symbol, ).price() } }, ) } + MultilineLambdaItParameter:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate${ PortfoliosWithThisCurrency( currencyRawId = currencyRawId, portfolios = it.toList(), ) } + MultilineLambdaItParameter:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate${ when (it) { AddButtonState.Loading -> MyPortfolioUM.Loading AddButtonState.Available -> MyPortfolioUM.AddFirstToken( onAddClick = onAddClick, addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, ) AddButtonState.Unavailable -> MyPortfolioUM.Unavailable } } + MultilineLambdaItParameter:PortfolioDataLoader.kt$PortfolioDataLoader${ PortfolioData.CryptoCurrencyData( userWallet = wallet, status = it, actions = emptyList(), ) } + MultilineLambdaItParameter:PortfolioDataLoader.kt$PortfolioDataLoader${ PortfolioData.CryptoCurrencyData( userWallet = wallet, status = status, actions = it.states, ) } + MultilineLambdaItParameter:PortfolioItem.kt${ val onClick = state.tokenItemState.onItemClick if (onClick != null) { hapticManager.perform(TangemHapticEffect.View.ContextClick) onClick.invoke(it) } } + MultilineLambdaItParameter:PortfolioTokenUMConverter.kt$PortfolioTokenUMConverter.Companion${ if (it == QuickActionUM.Receive) { tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.CopyAddress, cryptoCurrencyData = cryptoData, ) } } + MultilineLambdaItParameter:PortfolioTokenUMConverter.kt$PortfolioTokenUMConverter.Companion${ when (it) { QuickActionUM.Buy -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Buy, cryptoCurrencyData = cryptoData, ) is QuickActionUM.Exchange -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Exchange, cryptoCurrencyData = cryptoData, ) QuickActionUM.Receive -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Receive, cryptoCurrencyData = cryptoData, ) QuickActionUM.Stake -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Stake, cryptoCurrencyData = cryptoData, ) } } + MultilineLambdaItParameter:PricePerformanceBlock.kt${ currentInterval = it state.onIntervalChanged(it) } + MultilineLambdaItParameter:SecurityScoreConverter.kt$SecurityScoreConverter${ SecurityScoreBottomSheetContent.SecurityScoreProviderUM( name = it.providerName, lastAuditDate = it.lastAuditDate?.let { date -> MarketsDateTimeFormatters.formatAsDate(date.millis) }, score = it.securityScore, urlData = it.urlData?.let { urlData -> SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( fullUrl = urlData.fullUrl, rootHost = urlData.rootHost, ) }, iconUrl = it.iconUrl, ) } + MultilineLambdaItParameter:TokenActionsContent.kt${ key(it.title) { ActionRow( state = it, onClick = { state.quickActions.onQuickActionClick(it) }, onLongClick = { state.quickActions.onQuickActionLongClick(it) }, ) } } + MultilineLambdaItParameter:TokenMarketBlockModel.kt$TokenMarketBlockModel${ it.onSome { res -> quotesState = QuotesState( currentPrice = res.fiatRate, h24Percent = res.priceChange, ) state.value = state.value.copy( currentPrice = res.fiatRate.format { fiat( // TODO get currency from quotes use case AND-8022 fiatCurrencyCode = currentAppCurrency.value.code, // TODO get currency from quotes use case AND-8022 fiatCurrencySymbol = currentAppCurrency.value.symbol, ).price() }, h24Percent = res.priceChange.format { percent() }, priceChangeType = PriceChangeType.fromBigDecimal(res.priceChange), ) } } + MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ pricePerformanceConverter.convert( value = it, currentPrice = value.quotes.currentPrice, ) } + MultilineLambdaItParameter:TokensPortfolioUMConverter.kt$TokensPortfolioUMConverter${ it.copy( isQuickActionsShown = if (it.matchWith(cryptoData)) { !it.isQuickActionsShown } else { false }, ) } + NamedArguments:AvailableToAddDataConverter.kt$AvailableToAddDataConverter$createCryptoCurrency(wallet, it, marketParams, this.account) + NamedArguments:MarketsListModel.kt$MarketsListModel$combine( mainMarketsListManager.uiItems, mainMarketsListManager.isInInitialLoadingErrorState, getStakingNotificationMaxApyUseCase(), getUserCountryUseCase.invoke(), ) { uiItems, isInInitialLoadingErrorState, stakingNotificationMaxApy, userCountry -> MarketsItemsData( items = uiItems, isInErrorState = isInInitialLoadingErrorState, isSearchNotFound = false, stakingNotificationMaxApy = stakingNotificationMaxApy, userCountry = userCountry, ) } + NamedArguments:MarketsListModel.kt$MarketsListModel$combine( searchMarketsListManager.uiItems, searchMarketsListManager.isInInitialLoadingErrorState, searchMarketsListManager.isSearchNotFoundState, getStakingNotificationMaxApyUseCase(), getUserCountryUseCase.invoke(), ) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, stakingMaxApy, userCountry -> MarketsItemsData( items = uiItems, isInErrorState = isInInitialLoadingErrorState, isSearchNotFound = isSearchNotFoundState, stakingNotificationMaxApy = stakingMaxApy, userCountry = userCountry, ) } + NestedScopeFunctions:DescriptionConverter.kt$DescriptionConverter$let { fullDescription -> stringReference(fullDescription) } + NoNameShadowing:MarketsPortfolioModel.kt$MarketsPortfolioModel${ it.isMultiCurrency } + NoNameShadowing:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { it.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { it.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } } + NoNameShadowing:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), body = if (it.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { it.body }, ) } + NoNameShadowing:MyPortfolioUMFactory.kt$MyPortfolioUMFactory${ networkIds.contains(it.status.currency.network.backendId) } + NoNameShadowing:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$portfolio + NonBooleanPropertyPrefixedWithIs:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$val isInInitialLoadingErrorState = batchFlow.state .map { it.status is PaginationStatus.InitialLoadingError } .distinctUntilChanged() .stateIn( scope = modelScope, started = SharingStarted.Eagerly, initialValue = false, ) + NonBooleanPropertyPrefixedWithIs:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$val isSearchNotFoundState = batchFlow.state .map { currentSearchText().isNullOrEmpty().not() && it.status is PaginationStatus.EndOfPagination && it.data.isEmpty() } .distinctUntilChanged() .stateIn( scope = modelScope, started = SharingStarted.Eagerly, initialValue = false, ) + NonBooleanPropertyPrefixedWithIs:MarketsListModel.kt$MarketsListModel$val isVisibleOnScreen = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:MarketsListUMStateManager.kt$MarketsListUMStateManager$val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged() + NonBooleanPropertyPrefixedWithIs:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$val isVisibleOnScreen = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:TokenActionsHandler.kt$TokenActionsHandler$private val isDemoCardUseCase: IsDemoCardUseCase + NullableToStringCall:MarketsListItemUM.kt$MarketsListItemUM$marketCap.toString() + PropertyUsedBeforeDeclaration:MarketsListModel.kt$MarketsListModel$activeListManager + PropertyUsedBeforeDeclaration:MarketsListUMStateManager.kt$MarketsListUMStateManager$state + PropertyUsedBeforeDeclaration:MarketsPortfolioModel.kt$MarketsPortfolioModel$_state + PropertyUsedBeforeDeclaration:MarketsPortfolioModel.kt$MarketsPortfolioModel$currentAppCurrency + PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$accountHeader + PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$accountToken + PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$sampleToken + PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$walletHeader + PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$walletPortfolioHeader + ReusedModifierInstance:EntryBottomSheetContent.kt$BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) + ReusedModifierInstance:PortfolioQuickActions.kt$Column(modifier = modifier) { actions.fastForEach { action -> LineSeparator() QuickActionItem( state = action, onClick = { onActionClick(action) }, onLongClick = { onActionLongClick(action) }.takeIf { action.longClickAvailable }, ) } } + ReusedModifierInstance:TokenActionsContent.kt$SecondaryButton( modifier = modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_later), onClick = state.onLaterClick, ) + SuspendFunWithFlowReturnType:AddToPortfolioModel.kt$AddToPortfolioModel$suspend + UnnecessaryEventHandlerParameter:SecurityScoreBottomSheet.kt$onLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit + UnnecessaryLet:PortfolioAnalyticsEvent.kt$PortfolioAnalyticsEvent.EventBuilder$let { put("Source", source) } + UnnecessaryLet:SecurityScoreBottomSheet.kt$let { Text( text = providerUM.lastAuditDate, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) } + UnsafeCallOnNullableType:DefaultMarketsPortfolioComponent.kt$DefaultMarketsPortfolioComponent$model.newAddToPortfolioManager!! + UnsafeCallOnNullableType:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$previousList!! + UnsafeCallOnNullableType:PricePerformanceConverter.kt$PricePerformanceConverter$high!! + UnsafeCallOnNullableType:PricePerformanceConverter.kt$PricePerformanceConverter$low!! + UseAnyOrNoneInsteadOfFind:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$find { account -> account.addedCurrency.isNotEmpty() } + UseEmptyCounterpart:MarketDetailsAnalyticsEvent.kt$MarketDetailsAnalyticsEvent$mapOf() + UseEmptyCounterpart:MarketsListAnalyticsEvent.kt$MarketsListAnalyticsEvent$mapOf() + UseEmptyCounterpart:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$setOf() + UseEmptyCounterpart:PortfolioAnalyticsEvent.kt$PortfolioAnalyticsEvent$mapOf() + UseOrEmpty:DescriptionConverter.kt$DescriptionConverter$value.fullDescription ?: "" + UseOrEmpty:Formatters.kt$percent?.format { percent() } ?: "" + UseOrEmpty:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$percent?.format { percent() } ?: "" + UseOrEmpty:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$allActions[currencyStatus.currency]?.states ?: emptyList() + UseOrEmpty:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$portfolio.portfolios .find { it.accountsWithAdded.find { account -> account.addedCurrency.isNotEmpty() } != null } ?.userWallet ?.let { setOf(it.walletId to currency.currency.id) } ?: setOf() + VarCouldBeVal:MarketsListModel.kt$MarketsListModel$private var updateQuotesJob = JobHolder() + VarCouldBeVal:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$private var quotesJob = JobHolder() + + diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt index 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..ed74502649 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 @@ -1,19 +1,17 @@ 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.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 import com.tangem.features.markets.portfolio.add.impl.model.AddTokenModel -import com.tangem.features.markets.portfolio.add.impl.ui.AddTokenContent +import com.tangem.common.ui.addtoken.AddTokenContent import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -38,7 +36,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..4bbeec4cee 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 @@ -23,16 +24,26 @@ internal class AvailableToAddDataConverter @Inject constructor( ) { suspend fun convert( - balances: Map, + balances: Map, availableNetworks: Set, 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() + suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { + val currencies = availableNetworks + .mapNotNull { createCryptoCurrency(wallet, it, marketParams, this.account) } + + if (currencies.isEmpty()) return null + + val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies) + .fold( + ifEmpty = { emptySet() }, + ifSome = { map -> + map.values.flatMapTo(hashSetOf()) { statuses -> + statuses.map { it.currency.network } + } + }, + ) + return AvailableToAddAccount( account = this, availableNetworks = availableNetworks, @@ -41,13 +52,17 @@ internal class AvailableToAddDataConverter @Inject constructor( } suspend fun getAvailableToAddWallet( - entry: Map.Entry, + entry: Map.Entry, ): AvailableToAddWallet { - val (wallet, balance) = entry + val (_, balance) = entry + val wallet = balance.userWallet val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks) val accounts = balance.accountsBalance.accountStatuses val availableToAddAccounts: Map = accounts - .map { it.account.accountId to it.getAvailableToAddAccount(wallet) } + .mapNotNull { accountStatus -> + val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null + accountStatus.account.accountId to availableToAddAccount + } .filter { (_, account) -> account.availableToAddNetworks.isNotEmpty() } .toMap() return AvailableToAddWallet( @@ -60,9 +75,9 @@ internal class AvailableToAddDataConverter @Inject constructor( val availableToAddWallets: Map = balances .map { - val (wallet, balance) = it + val (walletId, _) = it val availableToAddWallet = getAvailableToAddWallet(it) - wallet.walletId to availableToAddWallet + walletId to availableToAddWallet } .filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() } .toMap() @@ -82,9 +97,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..f165c1bb40 --- /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 { actionsState -> + PortfolioData.CryptoCurrencyData( + userWallet = selectedPortfolio.userWallet, + status = actionsState.cryptoCurrencyStatus, + actions = actionsState.states, + ) + } + } + + private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( + flow = portfolioSelectorController.isAccountMode, + flow2 = portfolioSelectorController.selectedAccount, + transform = { isAccountMode, selectedAccountId -> + selectedAccountId ?: return@combine null + val availableToAddWallets = + data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null + val availableToAddAccount = + availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null + 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..8a4ec65436 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,14 +4,19 @@ 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 import com.tangem.features.markets.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress -import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM +import com.tangem.common.ui.addtoken.AddTokenUM import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -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..adcb476815 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 @@ -15,7 +16,7 @@ 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 -import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM +import com.tangem.common.ui.addtoken.AddTokenUM import javax.inject.Inject @ModelScoped @@ -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/DefaultAddToPortfolioManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt new file mode 100644 index 0000000000..c68c7b7ad5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -0,0 +1,73 @@ +package com.tangem.features.markets.portfolio.add.impl.ui + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager.State +import com.tangem.features.markets.portfolio.add.impl.converter.AvailableToAddDataConverter +import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class DefaultAddToPortfolioManager @AssistedInject constructor( + private val availableToAddDataConverter: AvailableToAddDataConverter, + @Assisted override val token: TokenMarketParams, + @Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + @Assisted val scope: CoroutineScope, + dispatchers: CoroutineDispatcherProvider, + portfolioFetcherFactory: PortfolioFetcher.Factory, +) : AddToPortfolioManager { + + private val _allAvailableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val allAvailableNetworks: Flow> = _allAvailableNetworks.asSharedFlow() + override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.All(onlyMultiCurrency = true), + scope = scope, + ) + + override val state: StateFlow = + combine( + flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), + flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(), + ) { balances, availableNetworks -> + val data = availableToAddDataConverter.convert( + balances = balances, + availableNetworks = availableNetworks, + marketParams = token, + ) + if (data.availableToAdd) { + State.AvailableToAdd(data) + } else { + State.NothingToAdd + } + } + .flowOn(dispatchers.default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = State.Init, + ) + + override fun setTokenNetworks(networks: List) { + _allAvailableNetworks.tryEmit(networks) + } + + @AssistedFactory + interface Factory : AddToPortfolioManager.Factory { + override fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + ): DefaultAddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt index 8f3e1d6da7..da8bdbd11a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -14,9 +14,10 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel +import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioRoute import com.tangem.features.markets.portfolio.impl.ui.MyPortfolio import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted @@ -28,19 +29,25 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: MarketsPortfolioComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : AppComponentContext by context, MarketsPortfolioComponent { private val model: MarketsPortfolioModel = getOrCreateModel(params) private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, - serializer = TokenReceiveConfig.serializer(), + serializer = MarketsPortfolioRoute.serializer(), handleBackButton = false, childFactory = ::bottomSheetChild, ) - override fun setTokenNetworks(networks: List) = model.setTokenNetworks(networks) - override fun setNoNetworksAvailable() = model.setNoNetworksAvailable() + override fun setTokenNetworks(networks: List) { + model.setTokenNetworks(networks) + } + + override fun setNoNetworksAvailable() { + model.setNoNetworksAvailable() + } @Composable override fun Content(modifier: Modifier) { @@ -52,15 +59,24 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( } private fun bottomSheetChild( - config: TokenReceiveConfig, + config: MarketsPortfolioRoute, componentContext: ComponentContext, - ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( - context = childByContext(componentContext), - params = TokenReceiveComponent.Params( - config = config, - onDismiss = model.bottomSheetNavigation::dismiss, - ), - ) + ): ComposableBottomSheetComponent = when (config) { + MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = model.newAddToPortfolioManager!!, + callback = model.addToPortfolioCallback, + ), + ) + is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + } @AssistedFactory interface Factory : MarketsPortfolioComponent.Factory { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt index 7d607c26d9..0b6b839c45 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -81,15 +81,11 @@ internal class AddToPortfolioBSContentUMFactory( userWalletId = selectedWallet.walletId, ), onContinueButtonClick = { - val alreadyAddedNetworkIds = portfolioData.walletsWithCurrencies[selectedWallet].orEmpty() - .map { it.status.currency.network.backendId } - .toSet() - onContinueClick( selectedWallet.walletId, portfolioUIData.addToPortfolioData.getAddedNetworks( userWalletId = selectedWallet.walletId, - alreadyAddedNetworkIds = alreadyAddedNetworkIds, + alreadyAddedNetworkIds = alreadyAddedNetworks, ), ) }, 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/MyPortfolioUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt index 44fd810a9a..9fe46dc75b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -99,6 +99,7 @@ internal class MyPortfolioUMFactory( val alreadyAddedNetworks = portfolioData.walletsWithCurrencies .filterAvailableNetworks(availableNetworks)[selectedWallet] + ?.filter { !it.status.currency.isCustom } ?.map { it.status.currency.network.backendId } ?.toSet() 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..c8f42a8843 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 @@ -165,15 +165,15 @@ internal class TokenActionsHandler @AssistedInject constructor( } private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { - val yield = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } + val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } ?.let { it as TokenActionsState.ActionState.Stake } - ?.yield ?: return + ?.option ?: return router.push( AppRoute.Staking( userWalletId = cryptoCurrencyData.userWallet.walletId, - cryptoCurrencyId = cryptoCurrencyData.status.currency.id, - yieldId = yield.id, + cryptoCurrency = cryptoCurrencyData.status.currency, + yieldId = option.integrationId, ), ) } 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/PreviewAddToPortfolioBSContentProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt index 3cc93f8bbc..16b7cf769f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -6,7 +6,6 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.markets.impl.R import com.tangem.features.markets.portfolio.impl.ui.state.AddToPortfolioBSContentUM import com.tangem.features.markets.portfolio.impl.ui.state.SelectNetworkUM @@ -24,7 +23,7 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider ) val userWallet = UserWalletItemUM( - id = UserWalletId("1"), + id = "1", name = stringReference("Wallet 1"), information = UserWalletItemUM.Information.Loaded(TextReference.Str("3 cards")), balance = UserWalletItemUM.Balance.Loading, 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..b6dd772abc 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,35 @@ package com.tangem.features.markets.portfolio.impl.ui.state +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.wallet.UserWalletId import kotlinx.collections.immutable.ImmutableList +@Immutable +internal sealed interface PortfolioListItem { + val id: String +} + +internal data class WalletHeader( + override val id: String, + val name: TextReference, +) : PortfolioListItem + +internal data class PortfolioHeader( + override val id: String, + val state: AccountTitleUM, +) : PortfolioListItem + internal data class PortfolioTokenUM( val tokenItemState: TokenItemState, val walletId: UserWalletId, val isBalanceHidden: Boolean, val isQuickActionsShown: Boolean, val quickActions: QuickActions, -) { +) : PortfolioListItem { + override val id: String = tokenItemState.id data class QuickActions( val actions: ImmutableList, 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/detekt-baseline-debug.xml b/features/nft/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..27148479aa --- /dev/null +++ b/features/nft/impl/detekt-baseline-debug.xml @@ -0,0 +1,42 @@ + + + + + BooleanPropertyNaming:NFTAssetUM.kt$NFTAssetUM$val showAllTraitsButton: Boolean + BooleanPropertyNaming:NFTAssetUM.kt$NFTAssetUM.BlockItem$val showInfoButton: Boolean + BooleanPropertyNaming:NFTAssetUM.kt$NFTAssetUM.Rarity.Content$val showDivider: Boolean + BooleanPropertyNaming:NFTCollectionsModel.kt$NFTCollectionsModel$val assetsFulfillQuery = if (query.isEmpty()) { true } else { when (val assets = it.assets) { is NFTCollection.Assets.Empty, is NFTCollection.Assets.Failed, is NFTCollection.Assets.Loading, -> false is NFTCollection.Assets.Value -> { assets.items.any { asset -> asset.name?.lowercase()?.contains(query.lowercase()) == true } } } } + BooleanPropertyNaming:NFTCollectionsModel.kt$NFTCollectionsModel$val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true + BooleanPropertyNaming:UpdateDataStateTransformer.kt$UpdateDataStateTransformer$val custom = derivationPath is Network.DerivationPath.Custom + CanBeNonNullable:NFTReceive.kt$bottomSheetConfig: TangemBottomSheetConfig? + MultilineLambdaItParameter:ChangeCollectionExpandedStateTransformer.kt$ChangeCollectionExpandedStateTransformer${ val collectionId = collection.collectionIdProvider() if (it.id == collectionId && it is NFTCollectionUM) { if (!it.isExpanded) { onFirstExpanded() } it.copy(isExpanded = !it.isExpanded) } else { it } } + MultilineLambdaItParameter:NFTCollectionsContent.kt${ key(it.id) { NFTCollectionWarning( modifier = Modifier .padding(top = TangemTheme.dimens.spacing16), state = it, ) } } + MultilineLambdaItParameter:NFTCollectionsModel.kt$NFTCollectionsModel${ ChangeCollectionExpandedStateTransformer( collection = collection, collectionIdProvider = collectionIdProvider, onFirstExpanded = { onFirstExpanded(collection) }, ).transform(it) } + MultilineLambdaItParameter:NFTCollectionsModel.kt$NFTCollectionsModel${ 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) } + MultilineLambdaItParameter:NFTCollectionsModel.kt$NFTCollectionsModel${ UpdateDataStateTransformer( nftCollections = nftCollections.filter(query), onReceiveClick = { params.onReceiveClick() }, onRetryClick = ::onRefresh, onExpandCollectionClick = ::onExpandCollectionClick, onRetryAssetsClick = ::onRetryAssetsClick, onAssetClick = { asset, collection -> params.onAssetClick(asset, collection) }, initialSearchBarFactory = ::getInitialSearchBar, collectionIdProvider = collectionIdProvider, ).transform(it) } + MultilineLambdaItParameter:NFTCollectionsModel.kt$NFTCollectionsModel${ it.copy( content = when (val content = it.content) { is NFTCollections.Content.Collections -> content.copy( collections = content.collections.orEmpty().filter { val assetsFulfillQuery = if (query.isEmpty()) { true } else { when (val assets = it.assets) { is NFTCollection.Assets.Empty, is NFTCollection.Assets.Failed, is NFTCollection.Assets.Loading, -> false is NFTCollection.Assets.Value -> { assets.items.any { asset -> asset.name?.lowercase()?.contains(query.lowercase()) == true } } } } val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true collectionFulfillQuery || assetsFulfillQuery }, ) is NFTCollections.Content.Error -> it.content }, ) } + MultilineLambdaItParameter:NFTCollectionsModel.kt$NFTCollectionsModel${ val assetsFulfillQuery = if (query.isEmpty()) { true } else { when (val assets = it.assets) { is NFTCollection.Assets.Empty, is NFTCollection.Assets.Failed, is NFTCollection.Assets.Loading, -> false is NFTCollection.Assets.Value -> { assets.items.any { asset -> asset.name?.lowercase()?.contains(query.lowercase()) == true } } } } val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true collectionFulfillQuery || assetsFulfillQuery } + MultilineLambdaItParameter:NFTDetailsUMFactory.kt$NFTDetailsUMFactory${ NFTAssetUM.BlockItem( title = stringReference(it.name), value = it.value, showInfoButton = false, ) } + MultilineLambdaItParameter:NFTDetailsUMFactory.kt$NFTDetailsUMFactory${ NFTAssetUM.Media.Content( url = it, ) } + MultilineLambdaItParameter:NFTReceiveModel.kt$NFTReceiveModel${ ShowReceiveBottomSheetTransformer( network = network, networkAddress = value.address, onDismissBottomSheet = ::onReceiveBottomSheetDismiss, onCopyClick = { text -> onCopyClick(text, network) }, onShareClick = { text -> onShareClick(text, network) }, ).transform(it) } + MultilineLambdaItParameter:NFTReceiveModel.kt$NFTReceiveModel${ UpdateDataStateTransformer( networks = filteredNetworks, onNetworkClick = ::onNetworkClick, ).transform(it) } + MultilineLambdaItParameter:NFTReceiveModel.kt$NFTReceiveModel${ it.copy( bottomSheetConfig = it.bottomSheetConfig?.copy(isShown = false), ) } + MultilineLambdaItParameter:UpdateDataStateTransformer.kt$UpdateDataStateTransformer${ NFTCollectionUM( id = it.collectionIdProvider(), networkIconId = getActiveIconRes(it.network.rawId), name = it.name.orEmpty(), description = TextReference.PluralRes( R.plurals.nft_collections_count, it.count, wrappedList(it.count), ), logoUrl = it.logoUrl, assets = it.transformAssets(), onExpandClick = { onExpandCollectionClick(it) }, isExpanded = it.isExpanded(state), ) } + NoNameShadowing:NFTCollectionsModel.kt$NFTCollectionsModel${ val assetsFulfillQuery = if (query.isEmpty()) { true } else { when (val assets = it.assets) { is NFTCollection.Assets.Empty, is NFTCollection.Assets.Failed, is NFTCollection.Assets.Loading, -> false is NFTCollection.Assets.Value -> { assets.items.any { asset -> asset.name?.lowercase()?.contains(query.lowercase()) == true } } } } val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true collectionFulfillQuery || assetsFulfillQuery } + NonBooleanPropertyPrefixedWithIs:NFTCollectionsModel.kt$NFTCollectionsModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:NFTReceiveModel.kt$NFTReceiveModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NullableBooleanCheck:UpdateDataStateTransformer.kt$UpdateDataStateTransformer$(state.content as? NFTCollectionsUM.Content) ?.collections ?.filterIsInstance<NFTCollectionUM>() ?.firstOrNull { it.id == this.collectionIdProvider() } ?.isExpanded ?: false + NullableToStringCall:NFTCollectionsContent.kt$${item2?.id} + NullableToStringCall:NFTCollectionsModel.kt$NFTCollectionsModel$${network.derivationPath.value} + PropertyUsedBeforeDeclaration:NFTDetailsModel.kt$NFTDetailsModel$_state + ReusedModifierInstance:NFTCollectionsContent.kt$Box( modifier = modifier .fillMaxSize() .padding(bottom = bottomPadding), ) { Text( modifier = Modifier .align(Alignment.Center), text = stringResourceSafe(id = R.string.nft_empty_search), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, textAlign = TextAlign.Center, ) } + ReusedModifierInstance:NFTCollectionsLoading.kt$Card( modifier = modifier .fillMaxWidth() .padding( top = TangemTheme.dimens.spacing16, ), shape = RoundedCornerShape(TangemTheme.dimens.radius16), colors = CardDefaults.cardColors( containerColor = TangemTheme.colors.background.primary, contentColor = TangemTheme.colors.text.primary1, disabledContainerColor = TangemTheme.colors.background.primary, disabledContentColor = TangemTheme.colors.text.primary1, ), ) { Column { repeat(SHIMMER_ITEMS_COUNT) { CollectionPlaceholder() } } } + ReusedModifierInstance:NFTDetailsAsset.kt$Column( modifier = modifier .verticalScroll(scrollState) .padding( start = TangemTheme.dimens.spacing16, top = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing16, bottom = bottomPadding, ) .fillMaxSize(), ) { NFTDetailsLogo( state = state.media, modifier = Modifier .aspectRatio(1f), ) NFTDetailsInfoGroup( modifier = Modifier .padding(top = TangemTheme.dimens.spacing12), state = state.topInfo, onReadMoreClick = onReadMoreClick, ) NFTDetailsBlocksGroup( modifier = Modifier .padding(top = TangemTheme.dimens.spacing12), items = state.traits, title = resourceReference(R.string.nft_details_traits), action = if (state.showAllTraitsButton) { { NFTBlocksGroupAction( text = resourceReference(R.string.common_see_all), startIcon = { }, onClick = onSeeAllTraitsClick, ) } } else { null }, ) NFTDetailsBlocksGroup( modifier = Modifier .padding(top = TangemTheme.dimens.spacing12), items = state.baseInfoItems, title = resourceReference(R.string.nft_details_base_information), action = { NFTBlocksGroupAction( text = resourceReference(R.string.common_explore), startIcon = { NFTBlocksGroupActionIcon(iconRes = R.drawable.ic_compass_24) }, onClick = onExploreClick, ) }, ) } + ReusedModifierInstance:NFTDetailsInfoGroup.kt$Column( modifier = modifier .padding( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, ), ) { TextShimmer( modifier = Modifier .width(TangemTheme.dimens.size158), style = TangemTheme.typography.head, textSizeHeight = true, ) TextShimmer( modifier = Modifier .padding(top = TangemTheme.dimens.spacing4) .width(TangemTheme.dimens.size90), style = TangemTheme.typography.caption2, textSizeHeight = true, ) } + ReusedModifierInstance:NFTDetailsInfoGroup.kt$Column( modifier = modifier .padding( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, ), verticalArrangement = Arrangement.SpaceAround, ) { Text( text = state.cryptoPrice.resolveReference(), style = TangemTheme.typography.head, color = TangemTheme.colors.text.primary1, ) Text( modifier = Modifier .padding(top = TangemTheme.dimens.spacing4) .flicker(state.isFlickering), text = state.fiatPrice.resolveReference(), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) } + UnsafeCallOnNullableType:DefaultNFTComponent.kt$DefaultNFTComponent$portfolioFetcher!! + UseEmptyCounterpart:NFTCollectionsModel.kt$NFTCollectionsModel$listOf() + UseEmptyCounterpart:UpdateDataStateTransformer.kt$UpdateDataStateTransformer$listOf() + UseOrEmpty:UpdateDataStateTransformer.kt$UpdateDataStateTransformer$walletNFTCollections.collections.values.firstOrNull() ?: listOf() + + 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..9c4f5ad206 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,22 @@ package com.tangem.features.nft.collections.entity import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.extensions.TextReference +@Immutable +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 +24,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/detekt-baseline-debug.xml b/features/onboarding-v2/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..3eced7c5d9 --- /dev/null +++ b/features/onboarding-v2/impl/detekt-baseline-debug.xml @@ -0,0 +1,137 @@ + + + + + BooleanPropertyNaming:MultiWalletAccessCodeComponent.kt$MultiWalletAccessCodeComponent$val showBs by showBs.collectAsStateWithLifecycle() + BooleanPropertyNaming:MultiWalletAccessCodeUM.kt$MultiWalletAccessCodeUM$val atLeast4CharError: Boolean = false + BooleanPropertyNaming:MultiWalletAccessCodeUM.kt$MultiWalletAccessCodeUM$val codesNotMatchError: Boolean = false + BooleanPropertyNaming:MultiWalletBackupUM.kt$MultiWalletBackupUM$val addBackupButtonEnabled: Boolean = true + BooleanPropertyNaming:MultiWalletBackupUM.kt$MultiWalletBackupUM$val addBackupButtonLoading: Boolean = false + BooleanPropertyNaming:MultiWalletBackupUM.kt$MultiWalletBackupUM$val finalizeButtonEnabled: Boolean = false + BooleanPropertyNaming:MultiWalletBackupUM.kt$MultiWalletBackupUMDialog$val warningCancelColor: Boolean = false + BooleanPropertyNaming:MultiWalletCreateWalletModel.kt$MultiWalletCreateWalletModel$val cardDoesNotSupportBackup = result.data.card.settings.isBackupAllowed.not() + BooleanPropertyNaming:MultiWalletCreateWalletUM.kt$MultiWalletCreateWalletUM$val showOtherOptionsButton: Boolean + BooleanPropertyNaming:MultiWalletFinalizeUM.kt$MultiWalletFinalizeUM$val scanPrimary: Boolean = true + BooleanPropertyNaming:MultiWalletSeedPhraseUM.kt$MultiWalletSeedPhraseUM.GeneratedWordsCheck$val createWalletButtonEnabled: Boolean = false + BooleanPropertyNaming:MultiWalletSeedPhraseUM.kt$MultiWalletSeedPhraseUM.GeneratedWordsCheck$val createWalletButtonProgress: Boolean = false + BooleanPropertyNaming:MultiWalletSeedPhraseUM.kt$MultiWalletSeedPhraseUM.GeneratedWordsCheck.WordField$val error: Boolean + BooleanPropertyNaming:MultiWalletSeedPhraseUM.kt$MultiWalletSeedPhraseUM.Import$val createWalletEnabled: Boolean = false + BooleanPropertyNaming:MultiWalletSeedPhraseUM.kt$MultiWalletSeedPhraseUM.Import$val createWalletProgress: Boolean = false + BooleanPropertyNaming:MultiWalletUpgradeWalletModel.kt$MultiWalletUpgradeWalletModel$val cardDoesNotSupportBackup = result.data.card.settings.isBackupAllowed.not() + BooleanPropertyNaming:OnboardingDialogUM.kt$OnboardingDialogUM$val dismissWarningColor: Boolean = false + BooleanPropertyNaming:OnboardingDone.kt$var showConfetti by remember { mutableStateOf(false) } + BooleanPropertyNaming:OnboardingMultiWalletComponent.kt$OnboardingMultiWalletComponent.Params$val withSeedPhraseFlow: Boolean + BooleanPropertyNaming:OnboardingNoteCreateWalletUM.kt$OnboardingNoteCreateWalletUM$val createWalletInProgress: Boolean = false + BooleanPropertyNaming:OnboardingRoute.kt$OnboardingRoute.MultiWallet$val withSeedPhraseFlow: Boolean + BooleanPropertyNaming:OnboardingStepperComponent.kt$OnboardingStepperComponent.StepperState$val showProgress: Boolean + BooleanPropertyNaming:OnboardingTwin.kt$var toggle by remember { mutableStateOf(false) } + BooleanPropertyNaming:OnboardingTwinModel.kt$OnboardingTwinModel$val twinningInProgress = state is OnboardingTwinUM.ScanCard && (state.step == OnboardingTwinUM.ScanCard.Step.Second || state.step == OnboardingTwinUM.ScanCard.Step.Third) + BooleanPropertyNaming:OnboardingTwinUM.kt$OnboardingTwinUM.ResetWarning$val acceptToggle: Boolean = false + BooleanPropertyNaming:OnboardingVisaAccessCodeUM.kt$OnboardingVisaAccessCodeUM$val accessCodeHidden: Boolean = true + BooleanPropertyNaming:OnboardingVisaAccessCodeUM.kt$OnboardingVisaAccessCodeUM$val atLeastMinCharsError: Boolean = false + BooleanPropertyNaming:OnboardingVisaAccessCodeUM.kt$OnboardingVisaAccessCodeUM$val buttonLoading: Boolean = false + BooleanPropertyNaming:OnboardingVisaAccessCodeUM.kt$OnboardingVisaAccessCodeUM$val codesNotMatchError: Boolean = false + BooleanPropertyNaming:OnboardingVisaApproveUM.kt$OnboardingVisaApproveUM$val approveButtonLoading: Boolean = false + BooleanPropertyNaming:OnboardingVisaPinCodeUM.kt$OnboardingVisaPinCodeUM$val submitButtonEnabled: Boolean = true + BooleanPropertyNaming:OnboardingVisaPinCodeUM.kt$OnboardingVisaPinCodeUM$val submitButtonLoading: Boolean = false + BooleanPropertyNaming:OnboardingVisaRoute.kt$OnboardingVisaRoute.PinCode$val pinCodeValidationError: Boolean + BooleanPropertyNaming:OnboardingVisaRoute.kt$OnboardingVisaRoute.TangemWalletApproveOption$val allowNavigateBack: Boolean + BooleanPropertyNaming:OnboardingVisaWelcomeUM.kt$OnboardingVisaWelcomeUM$val continueButtonLoading: Boolean = false + BooleanPropertyNaming:SeedPhraseCheckUiStateBuilder.kt$SeedPhraseCheckUiStateBuilder$val allFieldsCorrect = newState.allFieldsCorrect() + BooleanPropertyNaming:SeedPhraseCheckUiStateBuilder.kt$SeedPhraseCheckUiStateBuilder$val correct = checkWordField(word = newText.text, shownIndex = shownIndex) + BooleanPropertyNaming:SeedPhraseState.kt$SeedPhraseState$val readyToImport: Boolean + BooleanPropertyNaming:Wallet1ChooseOptionModel.kt$Wallet1ChooseOptionModel$private var skipClicked = false + BooleanPropertyNaming:WalletArtwork.kt$WalletArtworksState.Leapfrog$val threeCards: Boolean = true + BooleanPropertyNaming:WalletArtwork.kt$WalletArtworksState.Stack$val threeCards: Boolean = true + CanBeNonNullable:DefaultOnboardingTwinComponent.kt$DefaultOnboardingTwinComponent.TwinInnerNavigationState$override val stackMaxSize: Int? = 5 + MaxChainedCallsOnSameLine:MultiWalletCreateWalletModel.kt$MultiWalletCreateWalletModel$result.data.card.settings.isBackupAllowed.not() + MaxChainedCallsOnSameLine:MultiWalletUpgradeWalletModel.kt$MultiWalletUpgradeWalletModel$result.data.card.settings.isBackupAllowed.not() + MaxChainedCallsOnSameLine:OnboardingTwinModel.kt$OnboardingTwinModel$params.scanResponse.card.issuer.publicKey.toHexString() + MaxChainedCallsOnSameLine:OnboardingVisaAccessCodeModel.kt$OnboardingVisaAccessCodeModel$result.data.signedActivationData.dataToSign.request.activationOrderInfo.customerWalletAddress + MaxChainedCallsOnSameLine:OnboardingVisaAccessCodeModel.kt$OnboardingVisaAccessCodeModel$result.data.signedActivationData.dataToSign.request.activationOrderInfo.orderId + MaxChainedCallsOnSameLine:OnboardingVisaAccessCodeModel.kt$OnboardingVisaAccessCodeModel$result.data.signedActivationData.dataToSign.request.cardWalletAddress + MaxChainedCallsOnSameLine:Wallet1ChooseOptionModel.kt$Wallet1ChooseOptionModel$params.multiWalletState.value.currentScanResponse.card.canSkipBackup + MultilineLambdaItParameter:DefaultOnboardingEntryComponent.kt$DefaultOnboardingEntryComponent${ it.copy( currentStep = state.stackSize, steps = state.stackMaxSize ?: 0, title = model.titleProvider.currentTitle.value, showProgress = state.stackMaxSize != null, ) } + MultilineLambdaItParameter:DefaultOnboardingEntryComponent.kt$DefaultOnboardingEntryComponent${ it.copy( currentStep = when (stack.active.configuration) { is OnboardingRoute.ManageTokens -> 7 is OnboardingRoute.AskBiometry -> 8 is OnboardingRoute.Done -> 9 else -> error("Unsupported route") }, steps = 9, title = when (stack.active.configuration) { is OnboardingRoute.ManageTokens -> resourceReference(R.string.main_manage_tokens) is OnboardingRoute.AskBiometry -> resourceReference(R.string.onboarding_navbar_save_wallet) is OnboardingRoute.Done -> resourceReference(R.string.onboarding_done_header) else -> error("Unsupported route") }, showProgress = true, ) } + MultilineLambdaItParameter:DefaultOnboardingVisaComponent.kt$DefaultOnboardingVisaComponent${ model.stackNavigation.replaceAll( OnboardingVisaRoute.PinCode(activationOrderInfo = it, pinCodeValidationError = false), ) } + MultilineLambdaItParameter:DefaultOnboardingVisaComponent.kt$DefaultOnboardingVisaComponent${ val activationReadyEvent = (it as? OnboardingVisaWelcomeComponent.DoneEvent.WelcomeBackDone)?.activationReadyEvent if (activationReadyEvent != null) { model.navigateFromActivationScreen(activationReadyEvent) } } + MultilineLambdaItParameter:GenerateSeedPhraseUiStateBuilder.kt$GenerateSeedPhraseUiStateBuilder${ switchType( newType = it, generatedWords12 = words12, generatedWords24 = words24, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( createWalletEnabled = false, wordsErrorText = null, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( invalidWords = emptyList<String>().toImmutableList(), wordsErrorText = null, createWalletEnabled = true, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( invalidWords = invalidWords.toImmutableList(), wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_wrong_words), createWalletEnabled = false, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ it.copy( wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_invalid_checksum), createWalletEnabled = false, ) } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ launchInterceptWords(wordsField = it) suggestNextWord(it) updateUiState { state -> state.copy(words = it) } } + MultilineLambdaItParameter:ImportSeedPhraseUiStateBuilder.kt$ImportSeedPhraseUiStateBuilder${ passphrase = it.text updateUiState { state -> state.copy(passPhrase = it) } } + MultilineLambdaItParameter:InvalidWordsColorTransformation.kt$InvalidWordsColorTransformation${ if (it == WORD_SEPARATOR) { append(WHITE_SPACE) } else if (wordsToBrush.contains(it)) { append(it.annotate()) } else { append(it) } } + MultilineLambdaItParameter:MultiWalletAccessCodeModel.kt$MultiWalletAccessCodeModel${ it.copy( accessCodeFirst = textFieldValue, atLeast4CharError = false, ) } + MultilineLambdaItParameter:MultiWalletAccessCodeModel.kt$MultiWalletAccessCodeModel${ it.copy( accessCodeSecond = textFieldValue, codesNotMatchError = false, ) } + MultilineLambdaItParameter:MultiWalletAccessCodeModel.kt$MultiWalletAccessCodeModel${ it.copy( step = MultiWalletAccessCodeUM.Step.AccessCode, accessCodeSecond = TextFieldValue(""), ) } + MultilineLambdaItParameter:MultiWalletAccessCodeModel.kt$MultiWalletAccessCodeModel${ it.copy( step = MultiWalletAccessCodeUM.Step.ConfirmAccessCode, atLeast4CharError = false, ) } + MultilineLambdaItParameter:MultiWalletAccessCodeModel.kt$MultiWalletAccessCodeModel${ it.copy( step = MultiWalletAccessCodeUM.Step.Intro, accessCodeFirst = TextFieldValue(""), accessCodeSecond = TextFieldValue(""), ) } + MultilineLambdaItParameter:MultiWalletBackupComponent.kt$MultiWalletBackupComponent${ it.copy( stackSize = 5, stackMaxSize = 9, ) } + MultilineLambdaItParameter:MultiWalletBackupModel.kt$MultiWalletBackupModel${ it.copy( card2 = if (state.value.numberOfBackupCards == 1) backupCardInfo else it.card2, card3 = if (state.value.numberOfBackupCards == 2) backupCardInfo else it.card3, ) } + MultilineLambdaItParameter:MultiWalletBackupModel.kt$MultiWalletBackupModel${ it.copy( dialog = onlyOneBackupDeviceDialog( onDismiss = { _uiState.update { it.copy(dialog = null) } }, onConfirm = { onFinalizeClick(fromDialog = true) }, ), ) } + MultilineLambdaItParameter:MultiWalletBackupModel.kt$MultiWalletBackupModel${ it.copy( numberOfBackupCards = it.numberOfBackupCards + 1, ) } + MultilineLambdaItParameter:MultiWalletCreateWalletComponent.kt$MultiWalletCreateWalletComponent${ it.copy( stackSize = 2, stackMaxSize = 9, ) } + MultilineLambdaItParameter:MultiWalletCreateWalletModel.kt$MultiWalletCreateWalletModel${ it.copy( currentScanResponse = it.currentScanResponse.copy( card = result.data.card, derivedKeys = result.data.derivedKeys, primaryCard = result.data.primaryCard, ), ) } + MultilineLambdaItParameter:MultiWalletFinalizeComponent.kt$MultiWalletFinalizeComponent${ it.copy( stackSize = 7, stackMaxSize = 9, ) } + MultilineLambdaItParameter:MultiWalletScanPrimaryComponent.kt$MultiWalletScanPrimaryComponent${ it.copy( stackSize = 4, stackMaxSize = 9, ) } + MultilineLambdaItParameter:MultiWalletScanPrimaryModel.kt$MultiWalletScanPrimaryModel${ it.copy( currentScanResponse = scanResponse.copy( primaryCard = result.data, ), ) } + MultilineLambdaItParameter:MultiWalletSeedPhraseComponent.kt$MultiWalletSeedPhraseComponent${ // change stepper state based on the stack of the current step @Suppress("MagicNumber") params.innerNavigation.update { st -> st.copy( stackSize = 3 + it.order, stackMaxSize = 11, ) } val title = when (it) { is MultiWalletSeedPhraseUM.Import -> R.string.onboarding_seed_intro_button_import is MultiWalletSeedPhraseUM.GenerateSeedPhrase, is MultiWalletSeedPhraseUM.GeneratedWordsCheck, is MultiWalletSeedPhraseUM.Start, -> R.string.onboarding_create_wallet_header } params.parentParams.titleProvider.changeTitle(text = resourceReference(title)) } + MultilineLambdaItParameter:MultiWalletSeedPhraseModel.kt$MultiWalletSeedPhraseModel${ if (it !is T) return@update it block(it) } + MultilineLambdaItParameter:MultiWalletSeedPhraseModel.kt$MultiWalletSeedPhraseModel${ it.copy( generatedWords12 = words12, generatedWords24 = words24, ) } + MultilineLambdaItParameter:MultiWalletSeedPhraseWords.kt${ EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word1", ) } + MultilineLambdaItParameter:MultiWalletSeedPhraseWords.kt${ Text( text = pluralStringResourceSafe( id = R.plurals.onboarding_seed_generate_words_count, count = it.length, it.length, ), modifier = Modifier .padding(vertical = TangemTheme.dimens.spacing10) .fillMaxWidth(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, ) } + MultilineLambdaItParameter:MultiWalletSeedPhraseWords.kt${ when { state.option == GeneratedWordsType.Words12 && it == GeneratedWordsType.Words24 -> { state.onOptionChange(it) } state.option == GeneratedWordsType.Words24 && it == GeneratedWordsType.Words12 -> { state.onOptionChange(it) } } } + MultilineLambdaItParameter:MultiWalletUpgradeWalletComponent.kt$MultiWalletUpgradeWalletComponent${ it.copy( stackSize = 2, stackMaxSize = 9, ) } + MultilineLambdaItParameter:MultiWalletUpgradeWalletModel.kt$MultiWalletUpgradeWalletModel${ it.copy( currentScanResponse = it.currentScanResponse.copy( card = result.data.card, derivedKeys = result.data.derivedKeys, primaryCard = result.data.primaryCard, ), ) } + MultilineLambdaItParameter:OnboardingTwinModel.kt$OnboardingTwinModel${ Timber.e("Unable to save user wallet: $it") setLoading(false) return@coroutineScope } + MultilineLambdaItParameter:OnboardingTwinModel.kt$OnboardingTwinModel${ Timber.e("Unable to save user wallet: $it") setLoading(false) return@launch } + MultilineLambdaItParameter:OnboardingTwinModel.kt$OnboardingTwinModel${ it.copy( isLoading = false, artworkStep = it.artworkStep.next(), step = OnboardingTwinUM.ScanCard.Step.Second, onScanClick = { createSecondWallet(firstPublicKey = result.data.wallet.publicKey.toHexString()) }, ) } + MultilineLambdaItParameter:OnboardingTwinModel.kt$OnboardingTwinModel${ it.copy( isLoading = false, artworkStep = it.artworkStep.next(), step = OnboardingTwinUM.ScanCard.Step.Third, onScanClick = { createThirdWallet( secondCardPublicKey = result.data.wallet.publicKey, ) }, ) } + MultilineLambdaItParameter:OnboardingVisaAccessCodeModel.kt$OnboardingVisaAccessCodeModel${ it.copy( accessCodeFirst = textFieldValue, accessCodeSecond = TextFieldValue(), atLeastMinCharsError = false, ) } + MultilineLambdaItParameter:OnboardingVisaAccessCodeModel.kt$OnboardingVisaAccessCodeModel${ it.copy( accessCodeSecond = textFieldValue, codesNotMatchError = false, ) } + MultilineLambdaItParameter:OnboardingVisaAccessCodeModel.kt$OnboardingVisaAccessCodeModel${ onError(it) return@launch } + MultilineLambdaItParameter:OnboardingVisaApproveModel.kt$OnboardingVisaApproveModel${ onError(it) return@launch } + MultilineLambdaItParameter:OnboardingVisaInProgressModel.kt$OnboardingVisaInProgressModel${ if (it.isUnknown()) { uiMessageSender.send(unexpectedErrorAlertBS) } else { uiMessageSender.showErrorDialog(it) } analyticsEventHandler.send(VisaAnalyticsEvent.ErrorOnboarding(it)) delay(timeMillis = 2000) runShortPolling() return@launch } + MultilineLambdaItParameter:OnboardingVisaInProgressModel.kt$OnboardingVisaInProgressModel${ uiMessageSender.showErrorDialog(it) return } + MultilineLambdaItParameter:OnboardingVisaModel.kt$OnboardingVisaModel${ val derivedKey = it.derivedKeys[VisaUtilities.visaDefaultDerivationPath] ?: return@any false VisaWalletPublicKeyUtility.validateExtendedPublicKey( targetAddress = targetAddress, extendedPublicKey = derivedKey, ).onLeft { return@any VisaWalletPublicKeyUtility.findKeyWithoutDerivation( targetAddress = targetAddress, card = wallet.scanResponse.card, ).isRight() }.isRight() } + MultilineLambdaItParameter:OnboardingVisaOtherWalletModel.kt$OnboardingVisaOtherWalletModel${ if (it is VisaActivationRemoteState.AwaitingPinCode) { onDone.emit(it.activationOrderInfo) return@launch } } + MultilineLambdaItParameter:OnboardingVisaOtherWalletModel.kt$OnboardingVisaOtherWalletModel${ uiMessageSender.showErrorDialog(it) analyticsEventHandler.send(VisaAnalyticsEvent.ErrorOnboarding(it)) delay(timeMillis = 60_000) } + MultilineLambdaItParameter:OnboardingVisaPinCodeModel.kt$OnboardingVisaPinCodeModel${ it.copy( pinCode = pin, submitButtonEnabled = PinCodeValidation.validate(pin), error = if (isError) { analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ErrorPinValidation) resourceReference(R.string.visa_onboarding_pin_validation_error_message) } else { null }, ) } + MultilineLambdaItParameter:OnboardingVisaWelcomeModel.kt$OnboardingVisaWelcomeModel${ onError(it) return@launch } + MultilineLambdaItParameter:SeedPhraseCheckUiStateBuilder.kt$SeedPhraseCheckUiStateBuilder${ checkWordField( word = it.word.text, shownIndex = it.index, ) } + MultilineLambdaItParameter:TwinWalletArtwork.kt${ animationState = it val maxTime = maxOf( transition1.totalDurationNanos, transition2.totalDurationNanos, ) delay(TimeUnit.NANOSECONDS.toMillis(maxTime)) } + MultilineLambdaItParameter:Wallet1ChooseOptionComponent.kt$Wallet1ChooseOptionComponent${ it.copy( stackSize = 3, stackMaxSize = 9, ) } + MultilineLambdaItParameter:WalletArtwork.kt${ animationState = it val maxTime = maxOf( transition1.totalDurationNanos, transition2.totalDurationNanos, transition3.totalDurationNanos, ) delay(TimeUnit.NANOSECONDS.toMillis(maxTime)) } + NoNameShadowing:MultiWalletCreateWalletModel.kt$MultiWalletCreateWalletModel${ it.copy(resultUserWallet = userWallet) } + NoNameShadowing:MultiWalletUpgradeWalletModel.kt$MultiWalletUpgradeWalletModel${ it.copy(resultUserWallet = userWallet) } + NoNameShadowing:Wallet1ChooseOptionModel.kt$Wallet1ChooseOptionModel${ it.copy(resultUserWallet = userWallet) } + NonBooleanPropertyPrefixedWithIs:MultiWalletSeedPhraseModel.kt$MultiWalletSeedPhraseModel$private val isWalletAlreadySavedUseCase: IsWalletAlreadySavedUseCase + PropertyUsedBeforeDeclaration:MultiWalletCreateWalletModel.kt$MultiWalletCreateWalletModel$onDone + RedundantSuspendModifier:MultiWalletCreateWalletModel.kt$MultiWalletCreateWalletModel$suspend + RedundantSuspendModifier:OnboardingNoteCreateWalletModel.kt$OnboardingNoteCreateWalletModel$suspend + ReusedModifierInstance:DefaultOnboardingNoteComponent.kt$DefaultOnboardingNoteComponent$Content(modifier) + ReusedModifierInstance:DefaultOnboardingVisaComponent.kt$DefaultOnboardingVisaComponent$Content(modifier) + ReusedModifierInstance:MultiWalletAccessCodeEnter.kt$OutlineTextField( modifier = modifier .focusRequester(focusRequester) .fillMaxWidth(), value = if (reEnterAccessCodeState) { state.accessCodeSecond } else { state.accessCodeFirst }, onValueChange = if (reEnterAccessCodeState) { state.onAccessCodeSecondChange } else { state.onAccessCodeFirstChange }, label = stringResourceSafe(id = R.string.onboarding_wallet_info_title_third), isError = state.codesNotMatchError, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), caption = when { state.codesNotMatchError && reEnterAccessCodeState -> stringResourceSafe(R.string.onboarding_access_codes_doesnt_match) else -> null }, ) + ReusedModifierInstance:MultiWalletSeedPhraseImport.kt$OutlineTextFieldWithIcon( modifier = modifier .padding(horizontal = 16.dp) .fillMaxWidth(), value = state.passPhrase, onValueChange = state.passPhraseChange, iconResId = R.drawable.ic_information_24, iconColor = TangemTheme.colors.icon.informative, label = stringResourceSafe(id = R.string.common_passphrase), placeholder = stringResourceSafe(id = R.string.send_optional_field), onIconClick = state.onPassphraseInfoClick, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, keyboardType = KeyboardType.Password, ), ) + ReusedModifierInstance:OnboardingEntry.kt$Content(modifier = modifier) + ReusedModifierInstance:OnboardingStepper.kt$TangemTopAppBar( startButton = TopAppBarButtonUM.Back(onBackClick), endButton = TopAppBarButtonUM.Icon(iconRes = R.drawable.ic_chat_24, onClicked = onSupportButtonClick) .takeIf { state.steps != state.currentStep }, title = if (state.steps == state.currentStep) { resourceReference(R.string.common_done) } else { state.title }, containerColor = TangemTheme.colors.background.primary, modifier = modifier, ) + ReusedModifierInstance:OnboardingVisaAccessCode.kt$OutlineTextFieldWithIcon( modifier = modifier .padding(horizontal = 16.dp) .focusRequester(focusRequester) .fillMaxWidth(), iconResId = if (state.accessCodeHidden) { R.drawable.ic_eye_outline_24 } else { R.drawable.ic_eye_off_outline_24 }, iconColor = TangemTheme.colors.icon.primary1, onIconClick = state.onAccessCodeHideClick, value = if (reEnterAccessCodeState) { state.accessCodeSecond } else { state.accessCodeFirst }, onValueChange = if (reEnterAccessCodeState) { state.onAccessCodeSecondChange } else { state.onAccessCodeFirstChange }, label = stringResourceSafe(id = R.string.onboarding_wallet_info_title_third), isError = state.codesNotMatchError || state.atLeastMinCharsError, visualTransformation = if (state.accessCodeHidden) { PasswordVisualTransformation() } else { VisualTransformation.None }, caption = when { state.codesNotMatchError && reEnterAccessCodeState -> stringResourceSafe(R.string.onboarding_access_codes_doesnt_match) state.atLeastMinCharsError && !reEnterAccessCodeState -> stringResourceSafe(R.string.onboarding_access_code_too_short) else -> null }, ) + ReusedModifierInstance:OnboardingVisaPinCode.kt$PinCode( modifier = modifier, value = state.pinCode, onValueChange = state.onPinCodeChange, focusRequester = focusRequester, ) + ReusedModifierInstance:OnboardingVisaWelcome.kt$Image( painter = painterResource(R.drawable.img_card_visa), contentDescription = null, modifier = modifier .align(Alignment.Center) .onSizeChanged { cardHeightPx = it.height } .widthIn(max = 512.dp) .fillMaxWidth(), ) + UnreachableCode:OnboardingNoteCreateWalletModel.kt$OnboardingNoteCreateWalletModel$params.childParams.commonState.value.scanResponse ?: return@launch + UnsafeCallOnNullableType:MultiWalletBackupModel.kt$MultiWalletBackupModel$backupServiceHolder.backupService.get()!! + UnsafeCallOnNullableType:OnboardingEntryModel.kt$OnboardingEntryModel$userWalletsListManager.asLockable()?.isLocked!! + UseEmptyCounterpart:OnboardingEntryEvent.kt$OnboardingEntryEvent$mapOf() + UseEmptyCounterpart:OnboardingEvent.kt$OnboardingEvent$mapOf() + UseEmptyCounterpart:OnboardingEvent.kt$OnboardingEvent.Backup$mapOf() + UseEmptyCounterpart:OnboardingEvent.kt$OnboardingEvent.CreateWallet$mapOf() + UseEmptyCounterpart:OnboardingEvent.kt$OnboardingEvent.Topup$mapOf() + UseEmptyCounterpart:OnboardingEvent.kt$OnboardingEvent.Twins$mapOf() + UseEmptyCounterpart:OnboardingVisaAnalyticsEvent.kt$OnboardingVisaAnalyticsEvent$mapOf() + UseEmptyCounterpart:VisaAnalyticsEvent.kt$VisaAnalyticsEvent$mapOf() + UseEmptyCounterpart:WalletArtwork.kt$listOf() + UseOrEmpty:OnboardingVisaPinCode.kt$value.getOrNull(index)?.toString() ?: "" + + 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..0827c50a61 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, ) } @@ -127,8 +128,9 @@ internal class OnboardingEntryModel @Inject constructor( } private fun onMultiWalletOnboardingDone(userWallet: UserWallet.Cold) { + val mode = params.mode when { - params.mode == Mode.AddBackupWallet1 -> { + mode == Mode.AddBackupWallet1 -> { stackNavigation.replaceAll( OnboardingRoute.Done( mode = OnboardingDoneComponent.Mode.WalletCreated, @@ -136,7 +138,7 @@ internal class OnboardingEntryModel @Inject constructor( ), ) } - userWallet.scanResponse.cardTypesResolver.isMultiwalletAllowed() -> { + userWallet.scanResponse.cardTypesResolver.isMultiwalletAllowed() && mode !is Mode.UpgradeHotWallet -> { stackNavigation.replaceAll(OnboardingRoute.ManageTokens(userWallet)) } else -> { @@ -204,6 +206,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..7664244a73 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 @@ -1,13 +1,14 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model import androidx.compose.runtime.Stable +import arrow.core.getOrElse import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.ByteArrayKey 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.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase @@ -17,11 +18,13 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.isRing import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.requireColdWallet +import com.tangem.domain.models.wallet.requireHotWallet import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase import com.tangem.domain.wallets.usecase.UpdateWalletUseCase import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog import com.tangem.features.onboarding.v2.impl.R @@ -31,7 +34,9 @@ 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.operations.derivation.ExtendedPublicKeysMap import com.tangem.sdk.api.BackupServiceHolder import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.StringsSigns @@ -44,7 +49,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class MultiWalletFinalizeModel @Inject constructor( @@ -58,10 +63,12 @@ internal class MultiWalletFinalizeModel @Inject constructor( private val saveWalletUseCase: SaveWalletUseCase, private val getUserWalletsUseCase: GetWalletsUseCase, private val updateWalletUseCase: UpdateWalletUseCase, + private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase, private val cardRepository: CardRepository, private val onboardingRepository: OnboardingRepository, private val walletsRepository: WalletsRepository, private val uiMessageSender: UiMessageSender, + private val backupValidator: BackupValidator, ) : Model() { private val params = paramsContainer.require() @@ -70,12 +77,14 @@ internal class MultiWalletFinalizeModel @Inject constructor( private val backupCardIds = backupServiceHolder.backupService.get()?.backupCardIds.orEmpty() - private var walletHasBackupError = false + private var hasWalletBackupError = false private var hasRing = false val uiState = _uiState.asStateFlow() val onBackFlow = MutableSharedFlow() val onEvent = MutableSharedFlow() + val resetCardsModelCallbacks = ResetCardsModelCallbacks() + val startFullResetFlow = MutableSharedFlow() init { modelScope.launch { @@ -174,9 +183,9 @@ internal class MultiWalletFinalizeModel @Inject constructor( _uiState.update { st -> st.copy( step = MultiWalletFinalizeUM.Step.BackupDevice1, - isRing = backupService.backupCardsBatchIds.getOrNull(0)?.let { isRing(it) } == true, + isRing = backupService.backupCardsBatchIds.getOrNull(0)?.let(::isRing) == true, scanPrimary = false, - cardNumber = backupService.backupCardIds.firstOrNull()?.lastMasked() ?: "", + cardNumber = backupService.backupCardIds.firstOrNull()?.lastMasked().orEmpty(), ) } } @@ -199,7 +208,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( is CompletionResult.Success -> { val backupValidator = BackupValidator() if (backupValidator.isValidBackupStatus(CardDTO(result.data)).not()) { - walletHasBackupError = true + hasWalletBackupError = true } if (backupService.currentState == BackupService.State.Finished) { @@ -212,7 +221,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( isRing = backupService.backupCardsBatchIds .getOrNull(cardIndex + 1)?.let { isRing(it) } == true, cardNumber = backupService.backupCardIds.getOrNull(cardIndex + 1) - ?.lastMasked() ?: "", + ?.lastMasked().orEmpty(), ) } } @@ -229,16 +238,29 @@ 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 (hasWalletBackupError || !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, -> { - saveWalletUseCase( + saveWalletUseCase.invoke( userWallet = userWalletCreated.copy( scanResponse = scanResponse.updateScanResponseAfterBackup(), ), @@ -248,16 +270,16 @@ internal class MultiWalletFinalizeModel @Inject constructor( } OnboardingMultiWalletComponent.Mode.AddBackup -> { val userWallet = getUserWalletsUseCase.invokeSync() - .firstOrNull { - it is UserWallet.Cold && - it.scanResponse.primaryCard?.cardId == scanResponse.primaryCard?.cardId + .firstOrNull { userWallet -> + userWallet is UserWallet.Cold && + userWallet.scanResponse.primaryCard?.cardId == scanResponse.primaryCard?.cardId } ?: userWalletCreated - updateWalletUseCase( + updateWalletUseCase.invoke( userWalletId = userWallet.walletId, - update = { - it.requireColdWallet().copy( + update = { wallet -> + wallet.requireColdWallet().copy( scanResponse = scanResponse.updateScanResponseAfterBackup(), ) }, @@ -266,14 +288,20 @@ internal class MultiWalletFinalizeModel @Inject constructor( userWallet } is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { - saveWalletUseCase( - userWallet = userWalletCreated.copy( - scanResponse = scanResponse.updateScanResponseAfterBackup(), - ), - canOverride = true, - ) - // TODO [REDACTED_TASK_KEY] remove hot wallet after upgrade - userWalletCreated + updateWalletUseCase.invoke( + userWalletId = userWalletCreated.walletId, + update = { hotUserWallet -> + val wallet = hotUserWallet.requireHotWallet() + userWalletCreated.copy( + name = wallet.name, + scanResponse = scanResponse + .updateScanResponseAfterBackup() + .updateWithHotWallet(wallet), + ) + }, + ).getOrElse { + error("Failed to upgrade to cold wallet. Error: $it") + } } }.requireColdWallet() @@ -286,8 +314,8 @@ internal class MultiWalletFinalizeModel @Inject constructor( it.copy(resultUserWallet = userWallet) } - if (userWallet.scanResponse.cardTypesResolver.isWallet2() && userWallet.isImported) { - launch(NonCancellable) { walletsRepository.markWallet2WasCreated(userWallet.walletId) } + launch(NonCancellable) { + syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId) } // user wallet is fully created and saved, remove scan response from preferences @@ -304,7 +332,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( return requireNotNull( value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse) .backupCardsIds(backupCardIds.toSet()) - .hasBackupError(walletHasBackupError) + .hasBackupError(hasWalletBackupError) .build(), lazyMessage = { "User wallet not created" }, ) @@ -321,6 +349,14 @@ internal class MultiWalletFinalizeModel @Inject constructor( return copy(card = card) } + private fun ScanResponse.updateWithHotWallet(hotWallet: UserWallet.Hot): ScanResponse { + return copy( + derivedKeys = derivedKeys + hotWallet.wallets?.associate { + ByteArrayKey(it.publicKey) to ExtendedPublicKeysMap(it.derivedKeys) + }.orEmpty(), + ) + } + private fun handleActivationError() { _uiState.update { st -> st.copy( @@ -359,4 +395,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/build.gradle.kts b/features/onramp/impl/build.gradle.kts index e1d7b97469..44736bee00 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -13,6 +13,7 @@ android { dependencies { /** Project - API */ + implementation(projects.features.account.api) implementation(projects.features.onramp.api) implementation(projects.features.swap.domain) implementation(projects.features.swap.domain.api) diff --git a/features/onramp/impl/detekt-baseline-debug.xml b/features/onramp/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..4cc1802ed8 --- /dev/null +++ b/features/onramp/impl/detekt-baseline-debug.xml @@ -0,0 +1,13 @@ + + + + + NonBooleanPropertyPrefixedWithIs:AvailableSwapPairsModel.kt$AvailableSwapPairsModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:HotCryptoModel.kt$HotCryptoModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:OnrampAddTokenUiBuilder.kt$OnrampAddTokenUiBuilder$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:OnrampMainComponentModel.kt$OnrampMainComponentModel$private val isDemoCardUseCase: IsDemoCardUseCase + NonBooleanPropertyPrefixedWithIs:OnrampOperationModel.kt$OnrampOperationModel$private val isDemoCardUseCase: IsDemoCardUseCase + NonBooleanPropertyPrefixedWithIs:OnrampTokenListModel.kt$OnrampTokenListModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:SwapSelectTokensModel.kt$SwapSelectTokensModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + + 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..cf2c6af946 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 @@ -9,13 +9,17 @@ import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.features.onramp.alloffers.AllOffersComponent import com.tangem.features.onramp.alloffers.entity.AllOffersIntents +import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM import com.tangem.features.onramp.alloffers.entity.AllOffersStateFactory import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -27,6 +31,9 @@ internal class AllOffersModel @Inject constructor( paramsContainer: ParamsContainer, ) : Model(), AllOffersIntents { + val state: StateFlow + field = MutableStateFlow(AllOffersStateUM.Loading) + private var quotesJob: Job? = null private val stateFactory: AllOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) { @@ -39,9 +46,6 @@ internal class AllOffersModel @Inject constructor( private val params: AllOffersComponent.Params = paramsContainer.require() - private val _state: MutableStateFlow = MutableStateFlow(AllOffersStateUM.Loading) - val state: StateFlow = _state.asStateFlow() - init { subscribeOnAllOffers() analyticsEventHandler.send(OnrampAnalyticsEvent.PaymentMethodsScreenOpened) @@ -53,12 +57,20 @@ internal class AllOffersModel @Inject constructor( } override fun onPaymentMethodClicked(paymentMethodId: String) { - val contentState = state.value as? AllOffersStateUM.Content ?: return - val method = contentState.methods.firstOrNull { it.methodConfig.method.id == paymentMethodId } ?: return - analyticsEventHandler.send( - event = OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = method.methodConfig.method.name), - ) - _state.update { contentState.copy(currentMethod = method) } + var method: AllOffersPaymentMethodUM? = null + + state.update { contentState -> + if (contentState !is AllOffersStateUM.Content) return + method = contentState.methods.firstOrNull { it.methodConfig.method.id == paymentMethodId } ?: return + + contentState.copy(currentMethod = method) + } + + if (method != null) { + analyticsEventHandler.send( + event = OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = method.methodConfig.method.name), + ) + } } override fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) { @@ -69,16 +81,23 @@ internal class AllOffersModel @Inject constructor( tokenSymbol = params.cryptoCurrency.symbol, ), ) - onrampOfferAdvantagesUM.toAnalyticsEvent( + + val event = onrampOfferAdvantagesUM.toAnalyticsEvent( cryptoCurrencySymbol = params.cryptoCurrency.symbol, providerName = quote.provider.info.name, paymentMethodName = quote.paymentMethod.name, - )?.let { analyticsEventHandler::send } + ) + + if (event != null) { + analyticsEventHandler.send(event) + } + + dismiss() params.openRedirectPage(quote) } override fun onBackClicked() { - _state.update { stateFactory.getPaymentsState() } + state.update { stateFactory.getPaymentsState() } } override fun onRefresh() { @@ -95,7 +114,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, + ) + } }, ) } @@ -104,6 +128,6 @@ internal class AllOffersModel @Inject constructor( private fun handleOnrampError(onrampError: OnrampError) { Timber.e(onrampError.toString()) - _state.update { stateFactory.getOnrampErrorState(onrampError) } + state.update { stateFactory.getOnrampErrorState(onrampError) } } } \ No newline at end of file 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/confirmresidency/ConfirmResidencyComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ConfirmResidencyComponent.kt index 6942e44992..1957e8b15e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ConfirmResidencyComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ConfirmResidencyComponent.kt @@ -3,8 +3,8 @@ package com.tangem.features.onramp.confirmresidency import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.onramp.model.OnrampCountry import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampCountry internal interface ConfirmResidencyComponent : ComposableBottomSheetComponent { @@ -12,7 +12,7 @@ internal interface ConfirmResidencyComponent : ComposableBottomSheetComponent { val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val country: OnrampCountry, - val launchSepa: Boolean, + val isLaunchSepa: Boolean, val onDismiss: () -> Unit, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/model/ConfirmResidencyModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/model/ConfirmResidencyModel.kt index 724ec2e7a4..989bebc335 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/model/ConfirmResidencyModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/model/ConfirmResidencyModel.kt @@ -57,7 +57,7 @@ internal class ConfirmResidencyModel @Inject constructor( analyticsEventHandler.send(OnrampAnalyticsEvent.OnResidenceConfirm(country.name)) modelScope.launch { saveDefaultCountryUseCase.invoke(country) - if (params.launchSepa) { + if (params.isLaunchSepa) { onrampSaveDefaultCurrencyUseCase.invoke(EUR_CURRENCY) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt index f284662276..feff4ba69b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt @@ -1,21 +1,45 @@ package com.tangem.features.onramp.hottokens +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.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.decompose.router.stack.* 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.ComposableBottomSheetComponent +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.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.features.account.PortfolioSelectorComponent import com.tangem.features.onramp.hottokens.model.HotCryptoModel import com.tangem.features.onramp.hottokens.portfolio.OnrampAddToPortfolioComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioBSConfig +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenRoute import com.tangem.features.onramp.hottokens.ui.HotCrypto +import com.tangem.features.onramp.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -24,10 +48,40 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: HotCryptoComponent.Params, private val onrampAddToPortfolioComponentFactory: OnrampAddToPortfolioComponent.Factory, + private val accountsFeatureToggles: AccountsFeatureToggles, + portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, + addTokenComponentFactory: OnrampAddTokenComponent.Factory, ) : HotCryptoComponent, AppComponentContext by context { private val model: HotCryptoModel = getOrCreateModel(params) + private val portfolioSelectorComponent: PortfolioSelectorComponent? by lazy { + if (accountsFeatureToggles.isFeatureEnabled) { + portfolioSelectorComponentFactory.create( + context = child("portfolioSelectorComponent"), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = requireNotNull(model.portfolioFetcher), + controller = model.portfolioSelectorController, + ), + ) + } else { + null + } + } + private val addTokenComponent: OnrampAddTokenComponent? by lazy { + if (accountsFeatureToggles.isFeatureEnabled) { + addTokenComponentFactory.create( + context = child("addTokenComponent"), + params = OnrampAddTokenComponent.Params( + callbacks = model, + tokenToAdd = model.hotCryptoToAddDataFlow, + ), + ) + } else { + null + } + } + private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, serializer = null, @@ -35,6 +89,15 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( childFactory = ::bottomSheetChild, ) + private val childStack = childStack( + key = "addHotCryptoToPortfolioStack", + handleBackButton = true, + source = model.bottomSheetNavigationV2, + serializer = OnrampAddTokenRoute.serializer(), + initialConfiguration = OnrampAddTokenRoute.Empty, + childFactory = { config, _ -> contentChild(config) }, + ) + @Composable override fun Content(modifier: Modifier) { val state = model.state.collectAsStateWithLifecycle() @@ -43,6 +106,98 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( HotCrypto(state, modifier) bottomSheet.child?.instance?.BottomSheet() + if (accountsFeatureToggles.isFeatureEnabled) { + AddHotCryptoBottomSheet() + } + } + + @Composable + private fun AddHotCryptoBottomSheet() { + val stack by childStack.subscribeAsState() + val contentStack = remember { mutableStateOf(stack) } + val currentRoute = stack.active.configuration + val isNotEmpty = currentRoute != OnrampAddTokenRoute.Empty + if (isNotEmpty) { + contentStack.value = stack + } + + fun dismiss() = model.bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.Empty) + fun onBack() = if (childStack.backStack.isNotEmpty()) model.bottomSheetNavigationV2.pop() else dismiss() + + TangemModalBottomSheet( + scrollableContent = false, + onBack = ::onBack, + config = TangemBottomSheetConfig( + isShown = isNotEmpty, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + BottomSheetTitle( + stack = stack, + onBackClick = ::onBack, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + content = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + val paddingModifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ) + val isScrollableContent = when (stack.active.configuration) { + OnrampAddTokenRoute.PortfolioSelector -> false + OnrampAddTokenRoute.AddToken, + OnrampAddTokenRoute.Empty, + -> true + } + if (isScrollableContent) { + Column( + modifier = paddingModifier.verticalScroll(rememberScrollState()), + ) { + stack.active.instance.Content(modifier = Modifier) + } + } else { + stack.active.instance.Content(modifier = paddingModifier) + } + } + }, + ) + } + + @Composable + private fun BottomSheetTitle( + stack: ChildStack, + onBackClick: (() -> Unit), + modifier: Modifier = Modifier, + ) { + val title: TextReference = when (stack.active.configuration) { + OnrampAddTokenRoute.AddToken -> resourceReference(R.string.common_add_token) + OnrampAddTokenRoute.Empty -> TextReference.EMPTY + OnrampAddTokenRoute.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 bottomSheetChild( @@ -65,6 +220,12 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( } } + private fun contentChild(config: OnrampAddTokenRoute): ComposableContentComponent = when (config) { + OnrampAddTokenRoute.AddToken -> requireNotNull(addTokenComponent) + OnrampAddTokenRoute.PortfolioSelector -> requireNotNull(portfolioSelectorComponent) + OnrampAddTokenRoute.Empty -> ComposableContentComponent.EMPTY + } + @AssistedFactory interface Factory : HotCryptoComponent.Factory { override fun create(context: AppComponentContext, params: HotCryptoComponent.Params): DefaultHotCryptoComponent diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt index 4f55af238c..a9e87d2738 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt @@ -34,7 +34,7 @@ internal class HotTokenItemStateConverter( subtitleState = value.quoteStatus.getCryptoPriceState(appCurrency), fiatAmountState = null, subtitle2State = null, - onItemClick = onItemClick.let { onItemClick -> { onItemClick(it, value) } }, + onItemClick = { onItemClick(it, value) }, onItemLongClick = null, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt index f4a8cdc2eb..75812c5cef 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt @@ -4,26 +4,43 @@ 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.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.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM 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.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.onramp.GetHotCryptoUseCase import com.tangem.domain.onramp.model.HotCryptoCurrency import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.account.PortfolioSelectorController import com.tangem.features.onramp.hottokens.HotCryptoComponent import com.tangem.features.onramp.hottokens.converter.HotTokenItemStateConverter import com.tangem.features.onramp.hottokens.entity.HotCryptoUM +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent.AddHotCryptoData import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioBSConfig +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenRoute import com.tangem.features.onramp.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber @@ -40,23 +57,71 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") @ModelScoped internal class HotCryptoModel @Inject constructor( paramsContainer: ParamsContainer, - getHotCryptoUseCase: GetHotCryptoUseCase, - getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getHotCryptoUseCase: GetHotCryptoUseCase, + private val callbackDelegate: HotCryptoModelCallbackDelegate, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, override val dispatchers: CoroutineDispatcherProvider, -) : Model() { + private val hotCryptoPortfolioDataLoader: HotCryptoPortfolioDataLoader, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + val portfolioSelectorController: PortfolioSelectorController, + private val portfolioFetcherFactory: PortfolioFetcher.Factory, +) : Model(), OnrampAddTokenComponent.Callbacks by callbackDelegate { val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val state: StateFlow get() = _state - private val _state = MutableStateFlow(value = HotCryptoUM(items = persistentListOf())) + val portfolioFetcher: PortfolioFetcher? + val bottomSheetNavigationV2 = StackNavigation() + private val addHotCryptoJob = JobHolder() + val hotCryptoToAddDataFlow: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val state: StateFlow + field = MutableStateFlow(value = HotCryptoUM(items = persistentListOf())) private val params: HotCryptoComponent.Params = paramsContainer.require() init { + if (accountsFeatureToggles.isFeatureEnabled) { + portfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.Wallet(params.userWalletId), + scope = modelScope, + ) + combineData() + } else { + portfolioFetcher = null + combineDataOld() + } + } + + private fun combineData() { + combine( + flow = hotCryptoPortfolioDataLoader.loadPortfolioData(params.userWalletId), + flow2 = isAccountsModeEnabledUseCase.invoke(), + transform = { data, isAccountMode -> + HotTokenItemStateConverter( + appCurrency = data.appCurrency, + onItemClick = { tokenItemState, hotCryptoCurrency -> + startAddTokenFlow(currency = hotCryptoCurrency, hotCryptoPortfolioData = data) + }, + ) + .convertList(data.allHotCrypto) + .map(TokensListItemUM::Token) + }, + ) + .onEach { items -> state.update { HotCryptoUM(items = it.buildItems(items)) } } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun combineDataOld() { combine( flow = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }, flow2 = getHotCryptoUseCase(params.userWalletId), @@ -66,22 +131,19 @@ internal class HotCryptoModel @Inject constructor( .map(TokensListItemUM::Token) } .onEach { items -> - _state.update { - HotCryptoUM( - items = buildList { - if (items.isNotEmpty()) { - add(it.getOrCreateGroupTitle()) - } - - addAll(items) - } - .toImmutableList(), - ) - } + state.update { HotCryptoUM(items = it.buildItems(items)) } } .launchIn(modelScope) } + private fun HotCryptoUM.buildItems(items: List): ImmutableList = + buildList { + if (items.isNotEmpty()) { + add(getOrCreateGroupTitle()) + } + addAll(items) + }.toImmutableList() + private fun HotCryptoUM.getOrCreateGroupTitle(): TokensListItemUM { return items.firstOrNull() .takeIf { it is TokensListItemUM.GroupTitle } @@ -101,17 +163,107 @@ internal class HotCryptoModel @Inject constructor( ) } + private fun startAddTokenFlow(currency: HotCryptoCurrency, hotCryptoPortfolioData: HotCryptoPortfolioData) { + hotCryptoToAddDataFlow.resetReplayCache() + fun closeNavigationFlow() = bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.Empty) + channelFlow { + val userWallet = hotCryptoPortfolioData.wallet.userWallet + val isSingleAccount = hotCryptoPortfolioData.wallet.accounts.size == 1 + + if (isSingleAccount) { + val account = hotCryptoPortfolioData.wallet.accounts.first().account + val tokenToAdd = AddHotCryptoData( + cryptoCurrency = currency.cryptoCurrency, + userWallet = userWallet, + account = account, + isMorePortfolioAvailable = false, + ) + hotCryptoToAddDataFlow.emit(tokenToAdd) + bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.AddToken) + } else { + setupPortfolioSelector(currency, hotCryptoPortfolioData) + bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.PortfolioSelector) + + val tokenToAddStateFlow = portfolioSelectorController + .selectedAccountWithData(requireNotNull(portfolioFetcher)) + .filterNotNull() + .map { (_, selectedAccount) -> + AddHotCryptoData( + cryptoCurrency = currency.cryptoCurrency, + userWallet = userWallet, + account = selectedAccount, + isMorePortfolioAvailable = true, + ) + } + .shareIn(this, started = SharingStarted.Eagerly) + + val firstTokenToAdd = tokenToAddStateFlow.first() + hotCryptoToAddDataFlow.emit(firstTokenToAdd) + bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.AddToken) + + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { bottomSheetNavigationV2.pushNew(OnrampAddTokenRoute.PortfolioSelector) } + .launchIn(this) + + tokenToAddStateFlow + .onEach { newTokenToAdd -> hotCryptoToAddDataFlow.emit(newTokenToAdd) } + .onEach { bottomSheetNavigationV2.popToFirst() } + .launchIn(this) + } + + val addedToken = callbackDelegate.onTokenAdded + .receiveAsFlow() + .first() + params.onTokenClick(addedToken) + closeNavigationFlow() + channel.close() + } + .catch { throwable -> + Timber.e(throwable) + closeNavigationFlow() + } + .launchIn(modelScope) + .saveIn(addHotCryptoJob) + } + private fun onSuccessAdding(id: CryptoCurrency.ID) { modelScope.launch { getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( userWalletId = params.userWalletId, cryptoCurrencyId = id, ) - .onRight { + .onRight { status -> bottomSheetNavigation.dismiss() - params.onTokenClick(it) + params.onTokenClick(status) } .onLeft { Timber.d("Unable to get CryptoCurrencyStatus[$id]: $it") } } } + + private fun setupPortfolioSelector(hotCrypto: HotCryptoCurrency, hotCryptoPortfolioData: HotCryptoPortfolioData) { + portfolioSelectorController.selectAccount(null) + portfolioSelectorController.isEnabled.value = isEnabled@{ _, accountStatus -> + val isNotAddedHotCrypto = hotCryptoPortfolioData.wallet.accounts + .find { it.account.accountId == accountStatus.accountId } + ?.addedHotCrypto + ?.none { it.currency.id.rawCurrencyId == hotCrypto.cryptoCurrency.id.rawCurrencyId } == true + + return@isEnabled isNotAddedHotCrypto + } + } +} + +@ModelScoped +internal class HotCryptoModelCallbackDelegate @Inject constructor() : OnrampAddTokenComponent.Callbacks { + + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoPortfolioDataLoader.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoPortfolioDataLoader.kt new file mode 100644 index 0000000000..961019b158 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoPortfolioDataLoader.kt @@ -0,0 +1,84 @@ +package com.tangem.features.onramp.hottokens.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatuses +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountStatus +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.onramp.GetHotCryptoUseCase +import com.tangem.domain.onramp.model.HotCryptoCurrency +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import javax.inject.Inject + +@ModelScoped +internal class HotCryptoPortfolioDataLoader @Inject constructor( + private val appCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getHotCryptoUseCase: GetHotCryptoUseCase, + private val accountListSupplier: SingleAccountStatusListSupplier, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + +) { + + fun loadPortfolioData(userWalletId: UserWalletId): Flow = combine( + flow = appCurrencyUseCase.invokeOrDefault().distinctUntilChanged(), + flow2 = getHotCryptoUseCase(userWalletId).distinctUntilChanged(), + flow3 = accountListSupplier.invoke(userWalletId).distinctUntilChanged(), + flow4 = getUserWalletUseCase.invokeFlow(userWalletId).mapNotNull { it.getOrNull() }, + ) { appCurrency, allHotCrypto, walletAccounts, userWallet -> + + val hotCryptoCurrencies = allHotCrypto.map { hotCrypto -> hotCrypto.cryptoCurrency } + val mapOfAddedCurrencies: AccountCryptoCurrencyStatuses = getAccountCurrencyStatusUseCase + .invokeSync(userWalletId, hotCryptoCurrencies) + .getOrNull() + .orEmpty() + val accountsWithHotCrypto = walletAccounts.accountStatuses.map { accountStatus -> + val account: AccountStatus.CryptoPortfolio = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus + } + val addedHotCrypto = mapOfAddedCurrencies[account.account].orEmpty() + HotCryptoPortfolioData.Account( + account = account, + addedHotCrypto = addedHotCrypto, + ) + } + + HotCryptoPortfolioData( + allHotCrypto = allHotCrypto, + appCurrency = appCurrency, + wallet = HotCryptoPortfolioData.Wallet( + userWallet = userWallet, + statusList = walletAccounts, + accounts = accountsWithHotCrypto, + ), + ) + } +} + +internal data class HotCryptoPortfolioData( + val allHotCrypto: List, + val wallet: Wallet, + val appCurrency: AppCurrency, +) { + + data class Wallet( + val userWallet: UserWallet, + val statusList: AccountStatusList, + val accounts: List, + ) + + data class Account( + val account: AccountStatus.CryptoPortfolio, + val addedHotCrypto: List, + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/OnrampAddTokenComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/OnrampAddTokenComponent.kt new file mode 100644 index 0000000000..a965f4639b --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/OnrampAddTokenComponent.kt @@ -0,0 +1,59 @@ +package com.tangem.features.onramp.hottokens.portfolio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.addtoken.AddTokenContent +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.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.onramp.hottokens.portfolio.model.OnrampAddTokenModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class OnrampAddTokenComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: OnrampAddTokenModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val um = state.value ?: return + AddTokenContent( + modifier = modifier, + state = um, + ) + } + + data class Params( + val tokenToAdd: Flow, + val callbacks: Callbacks, + ) + + data class AddHotCryptoData( + val cryptoCurrency: CryptoCurrency, + val userWallet: UserWallet, + val account: AccountStatus, + val isMorePortfolioAvailable: Boolean, + ) + + interface Callbacks { + fun onChangePortfolioClick() + fun onTokenAdded(status: CryptoCurrencyStatus) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): OnrampAddTokenComponent + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt index 4db40ffeca..21019296d6 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.onramp.hottokens.portfolio.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.onramp.hottokens.portfolio.model.OnrampAddToPortfolioModel +import com.tangem.features.onramp.hottokens.portfolio.model.OnrampAddTokenModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +18,9 @@ internal interface OnrampAddToPortfolioModelModule { @IntoMap @ClassKey(OnrampAddToPortfolioModel::class) fun bindOnrampSelectCountryModel(model: OnrampAddToPortfolioModel): Model + + @Binds + @IntoMap + @ClassKey(OnrampAddTokenModel::class) + fun bindOnrampAddTokenModel(model: OnrampAddTokenModel): Model } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenRoute.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenRoute.kt new file mode 100644 index 0000000000..2dbe46e210 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenRoute.kt @@ -0,0 +1,16 @@ +package com.tangem.features.onramp.hottokens.portfolio.entity + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface OnrampAddTokenRoute { + + @Serializable + data object Empty : OnrampAddTokenRoute + + @Serializable + data object PortfolioSelector : OnrampAddTokenRoute + + @Serializable + data object AddToken : OnrampAddTokenRoute +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt new file mode 100644 index 0000000000..edd05f63b2 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt @@ -0,0 +1,103 @@ +package com.tangem.features.onramp.hottokens.portfolio.entity + +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.iconResId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent.AddHotCryptoData +import com.tangem.features.onramp.impl.R +import javax.inject.Inject + +@ModelScoped +internal class OnrampAddTokenUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, +) { + private val params = paramsContainer.require() + + private fun createNetwork(tokenToAdd: AddHotCryptoData): AddTokenUM.Network { + return AddTokenUM.Network( + icon = tokenToAdd.cryptoCurrency.network.iconResId, + name = stringReference(tokenToAdd.cryptoCurrency.network.name), + editable = false, + onClick = {}, + ) + } + + private suspend fun createPortfolio(tokenToAdd: AddHotCryptoData): PortfolioSelectUM { + val accountIcon: CryptoPortfolioIconUM? + val portfolioName: TextReference + val isAccountMode = isAccountsModeEnabledUseCase.invokeSync() + when (isAccountMode) { + false -> { + accountIcon = null + portfolioName = stringReference(tokenToAdd.userWallet.name) + } + true -> { + val accountStatus = tokenToAdd.account + portfolioName = accountStatus.account.accountName.toUM().value + accountIcon = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM() + } + } + } + return PortfolioSelectUM( + icon = accountIcon, + name = portfolioName, + isAccountMode = isAccountMode, + isMultiChoice = tokenToAdd.isMorePortfolioAvailable, + onClick = { params.callbacks.onChangePortfolioClick() }, + ) + } + + suspend fun updateContent( + isTangemIconVisible: Boolean, + onConfirmClick: () -> Unit, + tokenToAdd: AddHotCryptoData, + ): AddTokenUM { + val button = AddTokenUM.Button( + showProgress = false, + isTangemIconVisible = isTangemIconVisible, + text = resourceReference(R.string.common_add), + onConfirmClick = onConfirmClick, + isEnabled = true, + ) + val networkUM = createNetwork(tokenToAdd) + val portfolioUM = createPortfolio(tokenToAdd) + val currency = tokenToAdd.cryptoCurrency + val tokenItemState = TokenItemState.Content( + id = currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(currency), + titleState = TokenItemState.TitleState.Content(stringReference(currency.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return AddTokenUM( + tokenToAdd = tokenItemState, + network = networkUM, + portfolio = portfolioUM, + button = button, + ) + } + + companion object { + + fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy( + button = this.button.copy(showProgress = showProgress), + ) + } +} \ No newline at end of file diff --git a/features/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..66fa4f5693 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 @@ -4,9 +4,9 @@ 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.domain.wallets.usecase.DerivePublicKeysUseCase -import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase 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 @@ -40,8 +40,8 @@ internal class OnrampAddToPortfolioModel @Inject constructor( private val params: OnrampAddToPortfolioComponent.Params = paramsContainer.require() - val state: StateFlow get() = _state - private val _state = MutableStateFlow(value = getInitialState()) + val state: StateFlow + field = MutableStateFlow(value = getInitialState()) private fun getInitialState(): OnrampAddToPortfolioUM { return OnrampAddToPortfolioUM( @@ -72,9 +72,11 @@ internal class OnrampAddToPortfolioModel @Inject constructor( private fun onAddClick() { modelScope.launch { changeAddButtonProgressStatus(isProgress = true) - - derivePublicKeysUseCase(params.userWalletId, listOfNotNull(params.cryptoCurrency)).getOrElse { - Timber.e("Failed to derive public keys: $it") + derivePublicKeysUseCase( + userWalletId = params.userWalletId, + currencies = listOf(params.cryptoCurrency), + ).getOrElse { throwable -> + Timber.e("Failed to derive public keys: $throwable") changeAddButtonProgressStatus(isProgress = false) } @@ -89,8 +91,8 @@ internal class OnrampAddToPortfolioModel @Inject constructor( } private fun changeAddButtonProgressStatus(isProgress: Boolean) { - _state.update { - it.copy(addButtonUM = it.addButtonUM.copy(isProgress = isProgress)) + state.update { prevState -> + prevState.copy(addButtonUM = prevState.addButtonUM.copy(isProgress = isProgress)) } } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt new file mode 100644 index 0000000000..8a895a5fb9 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt @@ -0,0 +1,102 @@ +package com.tangem.features.onramp.hottokens.portfolio.model + +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.BackendId +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent.AddHotCryptoData +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenUiBuilder +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.onramp.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class OnrampAddTokenModel @Inject constructor( + paramsContainer: ParamsContainer, + private val uiBuilder: OnrampAddTokenUiBuilder, + private val messageSender: UiMessageSender, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val addTokenJob = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow(value = null) + + init { + params.tokenToAdd + .distinctUntilChanged() + .mapLatest { tokenToAdd: AddHotCryptoData -> + addTokenJob.cancel() + val backendId = tokenToAdd.cryptoCurrency.network.backendId + val userWalletId = tokenToAdd.account.accountId.userWalletId + val isTangemIconVisible = needColdWalletInteraction(userWalletId, backendId) + uiBuilder.updateContent( + tokenToAdd = tokenToAdd, + isTangemIconVisible = isTangemIconVisible, + onConfirmClick = { onAddClick(tokenToAdd).saveIn(addTokenJob) }, + ) + } + .onEach { newUI -> uiState.value = newUI } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onAddClick(tokenToAdd: AddHotCryptoData) = modelScope.launch(dispatchers.default) { + val um = uiState.value ?: return@launch + uiState.value = um.toggleProgress(true) + + val cryptoCurrency = tokenToAdd.cryptoCurrency + val accountId = tokenToAdd.account.accountId + manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + .onLeft { throwable -> + processError(error = throwable) + uiState.value = um.toggleProgress(false) + return@launch + } + + val status = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = accountId.userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ).getOrNull() + if (status == null) { + processError(error = null) + } else { + params.callbacks.onTokenAdded(status.status) + } + uiState.value = um.toggleProgress(false) + } + + private suspend fun needColdWalletInteraction(walletId: UserWalletId, backendId: BackendId): Boolean = + coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = walletId, + networksWithDerivationPath = mapOf(backendId 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/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt index d03c60d6d0..42967bcc47 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt @@ -57,7 +57,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor( userWalletId = params.userWalletId, cryptoCurrency = params.cryptoCurrency, country = config.country, - launchSepa = params.launchSepa, + isLaunchSepa = params.isLaunchSepa, onDismiss = { model.bottomSheetNavigation.dismiss() model.handleOnrampAvailable() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt index fa5cf89457..d4858314cf 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt @@ -3,9 +3,9 @@ package com.tangem.features.onramp.main 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 com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampSource -import com.tangem.domain.models.wallet.UserWalletId internal interface OnrampMainComponent : ComposableContentComponent { @@ -15,7 +15,7 @@ internal interface OnrampMainComponent : ComposableContentComponent { val source: OnrampSource, val openSettings: () -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, - val launchSepa: Boolean, + val isLaunchSepa: Boolean, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt index e375c15faf..72fa50f884 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampMainComponentUM.kt @@ -38,7 +38,7 @@ internal sealed interface OnrampMainComponentUM { override val buyButtonConfig: BuyButtonConfig = BuyButtonConfig( text = resourceReference(R.string.common_buy), onClick = {}, - enabled = false, + isEnabled = false, ) } @@ -54,5 +54,5 @@ internal sealed interface OnrampMainComponentUM { internal data class BuyButtonConfig( val text: TextReference, val onClick: () -> Unit, - val enabled: Boolean, + val isEnabled: Boolean, ) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt index 02de508683..b0a9d7773a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt @@ -72,7 +72,7 @@ internal class OnrampStateFactory( val contentState = state as? OnrampMainComponentUM.Content ?: return state return contentState.copy( - buyButtonConfig = contentState.buyButtonConfig.copy(enabled = false), + buyButtonConfig = contentState.buyButtonConfig.copy(isEnabled = false), amountBlockState = contentState.amountBlockState.copy( amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true), secondaryFieldModel = OnrampAmountSecondaryFieldUM.Error( @@ -92,7 +92,7 @@ internal class OnrampStateFactory( return when (state) { is OnrampMainComponentUM.Content -> state.copy( topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), - buyButtonConfig = state.buyButtonConfig.copy(enabled = false), + buyButtonConfig = state.buyButtonConfig.copy(isEnabled = false), amountBlockState = state.amountBlockState.copy( secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY), ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountFieldChangeConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountFieldChangeConverter.kt index 02c3f9fa33..ef9cff01ac 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountFieldChangeConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountFieldChangeConverter.kt @@ -66,7 +66,7 @@ internal class OnrampAmountFieldChangeConverter( amountFieldModel = amountFieldModel, secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY), ), - buyButtonConfig = buyButtonConfig.copy(enabled = false), + buyButtonConfig = buyButtonConfig.copy(isEnabled = false), providerBlockState = OnrampProviderBlockUM.Empty, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt index 4d466907aa..b3569cec78 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt @@ -69,7 +69,7 @@ internal class OnrampAmountStateFactory( return currentState.copy( amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading), providerBlockState = OnrampProviderBlockUM.Loading, - buyButtonConfig = currentState.buyButtonConfig.copy(enabled = false), + buyButtonConfig = currentState.buyButtonConfig.copy(isEnabled = false), errorNotification = null, ) } @@ -87,7 +87,7 @@ internal class OnrampAmountStateFactory( secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel, ), buyButtonConfig = currentState.buyButtonConfig.copy( - enabled = quote is OnrampQuote.Data, + isEnabled = quote is OnrampQuote.Data, onClick = { if (quote is OnrampQuote.Data) { onrampIntents.onBuyClick( @@ -166,7 +166,7 @@ internal class OnrampAmountStateFactory( onLinkClick = onrampIntents::onLinkClick, ), buyButtonConfig = currentState.buyButtonConfig.copy( - enabled = providerResult is SelectProviderResult.ProviderWithQuote, + isEnabled = providerResult is SelectProviderResult.ProviderWithQuote, onClick = { if (providerResult is SelectProviderResult.ProviderWithQuote) { onrampIntents.onBuyClick( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 6b3a4d73a6..18b31bb447 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -14,17 +14,17 @@ import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.onramp.* import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.OnrampCurrency 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.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.onramp.model.OnrampCurrency import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.main.OnrampMainComponent import com.tangem.features.onramp.main.entity.* @@ -38,6 +38,7 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.PeriodicTask import com.tangem.utils.coroutines.SingleTaskScheduler +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.isNullOrZero import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -69,35 +70,40 @@ internal class OnrampMainComponentModel @Inject constructor( private val params: OnrampMainComponent.Params = paramsContainer.require() - private var shouldForceChooseSepa = params.launchSepa + private var shouldForceChooseSepa = params.isLaunchSepa private var currencyToRestore: OnrampCurrency? = null val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + private val lastUpdateState = mutableStateOf(null) + private var userCountry: UserCountry? = null + + @Suppress("PropertyUsedBeforeDeclaration") private val stateFactory = OnrampStateFactory( - currentStateProvider = Provider { _state.value }, + currentStateProvider = Provider { state.value }, cryptoCurrency = params.cryptoCurrency, onrampIntents = this, ) + + val state: StateFlow + field = MutableStateFlow( + value = stateFactory.getInitialState( + currency = params.cryptoCurrency.name, + onClose = ::onCloseClick, + ), + ) + private val amountStateFactory = OnrampAmountStateFactory( - currentStateProvider = Provider { _state.value }, + currentStateProvider = Provider { state.value }, analyticsEventHandler = analyticsEventHandler, onrampIntents = this, cryptoCurrency = params.cryptoCurrency, needApplyFCARestrictions = Provider { userCountry.needApplyFCARestrictions() }, ) - private val _state: MutableStateFlow = MutableStateFlow( - value = stateFactory.getInitialState( - currency = params.cryptoCurrency.name, - onClose = ::onCloseClick, - ), - ) - private val quotesTaskScheduler = SingleTaskScheduler() - val state: StateFlow get() = _state.asStateFlow() - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - private val lastUpdateState = mutableStateOf(null) - private var userCountry: UserCountry? = null + private val quotesTaskScheduler = SingleTaskScheduler() init { userCountry = getUserCountryUseCase.invokeSync().getOrNull() @@ -106,7 +112,7 @@ internal class OnrampMainComponentModel @Inject constructor( modelScope.launch { clearOnrampCacheUseCase() - if (params.launchSepa) { + if (params.isLaunchSepa) { currencyToRestore = onrampGetDefaultCurrencyUseCase.invoke().getOrNull() onrampSaveDefaultCurrencyUseCase.invoke(EUR_CURRENCY) } @@ -132,7 +138,7 @@ internal class OnrampMainComponentModel @Inject constructor( } fun onProviderSelected(result: SelectProviderResult, isBestRate: Boolean) { - _state.update { amountStateFactory.getAmountSecondaryUpdatedState(result, isBestRate) } + state.update { amountStateFactory.getAmountSecondaryUpdatedState(result, isBestRate) } if (result.paymentMethod.id != SEPA_METHOD_ID) { shouldForceChooseSepa = false @@ -164,9 +170,9 @@ internal class OnrampMainComponentModel @Inject constructor( ifRight = { country -> if (country == null) return@onEach - val wasInitialLoading = _state.value is OnrampMainComponentUM.InitialLoading - _state.update { - if (it is OnrampMainComponentUM.InitialLoading) { + val wasInitialLoading = state.value is OnrampMainComponentUM.InitialLoading + state.update { prevState -> + if (prevState is OnrampMainComponentUM.InitialLoading) { stateFactory.getReadyState(country.defaultCurrency) } else { amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) @@ -175,7 +181,7 @@ internal class OnrampMainComponentModel @Inject constructor( updatePairsAndQuotes() - if (wasInitialLoading && params.launchSepa) { + if (wasInitialLoading && params.isLaunchSepa) { onAmountValueChanged(value = PREDEFINED_SEPA_AMOUNT, isValuePasted = true) } }, @@ -188,20 +194,25 @@ internal class OnrampMainComponentModel @Inject constructor( amountInputManager.query .filter(String::isNotEmpty) .collectLatest { _ -> - _state.update { amountStateFactory.getAmountSecondaryLoadingState() } + state.update { amountStateFactory.getAmountSecondaryLoadingState() } startLoadingQuotes() } } private suspend fun updatePairsAndQuotes() { - val state = state.value as? OnrampMainComponentUM.Content + state.update { prevState -> + val contentState = state.value as? OnrampMainComponentUM.Content ?: return@update prevState - if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { - _state.update { amountStateFactory.getAmountSecondaryLoadingState() } + if (contentState.amountBlockState.amountFieldModel.fiatValue.isNotEmpty()) { + amountStateFactory.getAmountSecondaryLoadingState() + } else { + prevState + } } + fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold( ifLeft = ::handleOnrampError, - ifRight = { _state.update { amountStateFactory.getAmountSecondaryResetState() } }, + ifRight = { state.update { amountStateFactory.getAmountSecondaryResetState() } }, ) startLoadingQuotes() } @@ -209,7 +220,7 @@ internal class OnrampMainComponentModel @Inject constructor( private fun handleOnrampError(onrampError: OnrampError) { Timber.e(onrampError.toString()) sendOnrampErrorAnalytic(onrampError) - _state.update { stateFactory.getOnrampErrorState(onrampError) } + state.update { stateFactory.getOnrampErrorState(onrampError) } } private fun startLoadingQuotes() { @@ -221,12 +232,15 @@ internal class OnrampMainComponentModel @Inject constructor( return PeriodicTask( delay = UPDATE_DELAY, task = { - runCatching { - val content = state.value as? OnrampMainComponentUM.Content ?: return@runCatching - if (content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) return@runCatching + runSuspendCatching { + val content = state.value as? OnrampMainComponentUM.Content ?: return@runSuspendCatching + val amountBlockState = content.amountBlockState + if (amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) { + return@runSuspendCatching + } fetchQuotesUseCase.invoke( userWallet = userWallet, - amount = content.amountBlockState.amountFieldModel.fiatAmount, + amount = amountBlockState.amountFieldModel.fiatAmount, cryptoCurrency = params.cryptoCurrency, ).onLeft(::handleOnrampError) } @@ -249,7 +263,7 @@ internal class OnrampMainComponentModel @Inject constructor( } override fun onAmountValueChanged(value: String, isValuePasted: Boolean) { - _state.update { amountStateFactory.getOnAmountValueChange(value, isValuePasted) } + state.update { amountStateFactory.getOnAmountValueChange(value, isValuePasted) } modelScope.launch { amountInputManager.update(value) } } @@ -290,7 +304,7 @@ internal class OnrampMainComponentModel @Inject constructor( } override fun onRefresh() { - _state.update { + state.update { stateFactory.getInitialState( currency = params.cryptoCurrency.name, onClose = router::pop, @@ -310,7 +324,7 @@ internal class OnrampMainComponentModel @Inject constructor( quotesTaskScheduler.cancelTask() modelScope.launch { - if (params.launchSepa) { + if (params.isLaunchSepa) { currencyToRestore?.let { onrampSaveDefaultCurrencyUseCase.invoke(it) } } } @@ -329,11 +343,11 @@ internal class OnrampMainComponentModel @Inject constructor( val quote = selectOrUpdateQuote(quotes) if (quote == null) { - _state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } + state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } lastUpdateState.value = null return } - _state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) } + state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) } } /** @@ -346,7 +360,7 @@ internal class OnrampMainComponentModel @Inject constructor( private fun selectOrUpdateQuote(quotes: List): OnrampQuote? { val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error } - val bestSepaQuote = if (params.launchSepa && shouldForceChooseSepa) { + val bestSepaQuote = if (params.isLaunchSepa && shouldForceChooseSepa) { quotes.filterIsInstance() .filter { it.paymentMethod.id == SEPA_METHOD_ID } .maxByOrNull { it.toAmount.value } @@ -362,9 +376,9 @@ internal class OnrampMainComponentModel @Inject constructor( val providerState = state?.providerBlockState as? OnrampProviderBlockUM.Content // Get current selected quote to update - val lastSelectedQuote = quotes.firstOrNull { - it.provider.id == providerState?.providerId && - it.paymentMethod.id == providerState.paymentMethod.id + val lastSelectedQuote = quotes.firstOrNull { quote -> + quote.provider.id == providerState?.providerId && + quote.paymentMethod.id == providerState.paymentMethod.id } // Check if selected updated quote is not error @@ -392,18 +406,20 @@ internal class OnrampMainComponentModel @Inject constructor( shouldForceChooseSepa = false } - _state.update { + state.update { amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes) } } private fun onRetryQuotes() { - _state.update { - (it as? OnrampMainComponentUM.Content)?.copy( + state.update { prevState -> + (prevState as? OnrampMainComponentUM.Content)?.copy( errorNotification = null, providerBlockState = OnrampProviderBlockUM.Loading, - amountBlockState = it.amountBlockState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading), - ) ?: it + amountBlockState = prevState.amountBlockState.copy( + secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading, + ), + ) ?: prevState } startLoadingQuotes() } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt index a493e1a7c9..0bdfa02446 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampAmountContent.kt @@ -104,8 +104,7 @@ private fun OnrampAmountSecondary(state: OnrampAmountSecondaryFieldUM) { top = TangemTheme.dimens.spacing8, start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, - ) - .testTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT), + ), contentAlignment = Alignment.Center, ) { when (state) { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampButtonComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampButtonComponent.kt index d63b3aa736..11e356cfef 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampButtonComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampButtonComponent.kt @@ -37,7 +37,7 @@ internal fun OnrampButtonComponent(state: OnrampMainComponentUM) { modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(id = R.string.common_buy), onClick = state.buyButtonConfig.onClick, - enabled = state.buyButtonConfig.enabled, + enabled = state.buyButtonConfig.isEnabled, ) } } @@ -78,19 +78,23 @@ private fun OnrampTosText(provider: OnrampProviderBlockUM.Content?) { textAlign = TextAlign.Center, ), onClick = { offset -> - clickableAnnotation.getStringAnnotations( + val tosAnnotations = clickableAnnotation.getStringAnnotations( tag = TERMS_OF_USE_KEY, start = offset, end = offset, - ).firstOrNull()?.let { + ) + + if (tosAnnotations.any()) { state.onLinkClick(termsOfUseLink) } - clickableAnnotation.getStringAnnotations( + val privacyPolicyAnnotations = clickableAnnotation.getStringAnnotations( tag = PRIVACY_POLICY_KEY, start = offset, end = offset, - ).firstOrNull()?.let { + ) + + if (privacyPolicyAnnotations.any()) { state.onLinkClick(privacyPolicyLink) } }, 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..4d417ad448 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 @@ -34,10 +34,7 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor( private val model: OnrampV2MainComponentModel = getOrCreateModel(params) init { - lifecycle.subscribe( - onStart = model::onStart, - onStop = model::onStop, - ) + lifecycle.subscribe(onStop = model::onStop) } private val bottomSheetSlot = childSlot( @@ -66,8 +63,11 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor( userWalletId = params.userWalletId, cryptoCurrency = params.cryptoCurrency, country = config.country, - launchSepa = false, - onDismiss = { model.bottomSheetNavigation.dismiss() }, + isLaunchSepa = false, + 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..aecbc43ee9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt @@ -9,19 +9,11 @@ import kotlinx.collections.immutable.ImmutableList @Immutable internal sealed interface OnrampOffersBlockUM { - val isBlockVisible: Boolean + data object Empty : OnrampOffersBlockUM - data object Empty : OnrampOffersBlockUM { - override val isBlockVisible: Boolean - get() = false - } - - data class Loading( - override val isBlockVisible: Boolean, - ) : OnrampOffersBlockUM + data object Loading : OnrampOffersBlockUM data class Content( - override val isBlockVisible: Boolean, val recentOffer: OnrampOfferUM?, val recommended: ImmutableList, val onrampAllOffersButtonConfig: OnrampAllOffersButtonConfig?, @@ -32,7 +24,6 @@ internal data class OnrampOfferUM( val category: OnrampOfferCategoryUM, val advantages: OnrampOfferAdvantagesUM, val paymentMethod: OnrampPaymentMethod, - val providerId: String, val providerName: String, val rate: String, val diff: TextReference?, @@ -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..67f09bf351 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,8 +71,9 @@ internal class OnrampOffersStateFactory( } } + if (allOffersUM.isEmpty()) return OnrampOffersBlockUM.Empty + return OnrampOffersBlockUM.Content( - isBlockVisible = currentState.offersBlockState.isBlockVisible, recentOffer = allOffersUM.firstOrNull { it.category == OnrampOfferCategoryUM.RecentlyUsed }, recommended = allOffersUM.filter { it.category == OnrampOfferCategoryUM.Recommended }.toPersistentList(), onrampAllOffersButtonConfig = if (offersBlocks.any { it.hasMoreOffers }) { @@ -107,6 +99,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..1fc8da3ee7 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 @@ -27,6 +25,7 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.PeriodicTask import com.tangem.utils.coroutines.SingleTaskScheduler +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.isNullOrZero import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -42,7 +41,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,44 +51,47 @@ internal class OnrampV2MainComponentModel @Inject constructor( val params = paramsContainer.require() - private val lastUpdateState = mutableStateOf(null) - private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) { OnrampAmountButtonUMStateFactory() } + @Suppress("PropertyUsedBeforeDeclaration") + private val stateFactory: OnrampV2StateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampV2StateFactory( + currentStateProvider = Provider { state.value }, + cryptoCurrency = params.cryptoCurrency, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + } + + val state: StateFlow + field = MutableStateFlow( + value = stateFactory.getInitialState( + currency = params.cryptoCurrency.name, + onClose = ::onCloseClick, + openSettings = ::openSettings, + ), + ) + + private val amountStateFactory: OnrampV2AmountStateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampV2AmountStateFactory( + currentStateProvider = Provider { state.value }, + analyticsEventHandler = analyticsEventHandler, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + } + private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) { OnrampOffersStateFactory( - currentStateProvider = Provider { _state.value }, + currentStateProvider = Provider { state.value }, onrampIntents = this, ) } - private val stateFactory = 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 _state: MutableStateFlow = MutableStateFlow( - value = stateFactory.getInitialState( - currency = params.cryptoCurrency.name, - onClose = ::onCloseClick, - openSettings = ::openSettings, - ), - ) private val quotesTaskScheduler = SingleTaskScheduler() - val state: StateFlow get() = _state.asStateFlow() val bottomSheetNavigation: SlotNavigation = SlotNavigation() val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } @@ -97,7 +99,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( modelScope.launch { clearOnrampCacheUseCase() } - + startLoadingQuotes() sendScreenOpenAnalytics() checkResidenceCountry() subscribeToAmountChanges() @@ -113,7 +115,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( } override fun onAmountValueChanged(value: String) { - _state.update { amountStateFactory.getOnAmountValueChange(value) } + state.update { amountStateFactory.getOnAmountValueChange(value) } modelScope.launch { amountInputManager.update(value) } } @@ -126,7 +128,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 +141,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) } @@ -150,7 +156,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( } override fun onRefresh() { - _state.update { + state.update { stateFactory.getInitialState( currency = params.cryptoCurrency.name, onClose = router::pop, @@ -160,27 +166,19 @@ internal class OnrampV2MainComponentModel @Inject constructor( modelScope.launch { clearOnrampCacheUseCase.invoke() checkResidenceCountry() + handleOnrampAvailable() } } - override fun onContinueClick() { - val currentState = _state.value - if (currentState is OnrampV2MainComponentUM.Content) { - _state.update { amountStateFactory.getShowProvidersState() } - } - } - - fun onStart() { - quotesTaskScheduler.scheduleTask( - scope = modelScope, - task = loadQuotesTask(), - ) - } - fun onStop() { quotesTaskScheduler.cancelTask() } + fun handleOnrampAvailable() { + subscribeToCountryAndCurrencyUpdates() + subscribeToQuotesUpdate() + } + private fun startLoadingQuotes() { quotesTaskScheduler.cancelTask() quotesTaskScheduler.scheduleTask(scope = modelScope, task = loadQuotesTask()) @@ -190,12 +188,16 @@ internal class OnrampV2MainComponentModel @Inject constructor( return PeriodicTask( delay = UPDATE_DELAY, task = { - runCatching { - val content = state.value as? OnrampV2MainComponentUM.Content ?: return@runCatching - if (content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) return@runCatching + runSuspendCatching { + val amountBlockState = (state.value as? OnrampV2MainComponentUM.Content)?.amountBlockState + ?: return@runSuspendCatching + + val fiatAmount = amountBlockState.amountFieldModel.fiatAmount + if (fiatAmount.value.isNullOrZero()) return@runSuspendCatching + fetchQuotesUseCase.invoke( userWallet = userWallet, - amount = content.amountBlockState.amountFieldModel.fiatAmount, + amount = amountBlockState.amountFieldModel.fiatAmount, cryptoCurrency = params.cryptoCurrency, ).onLeft(::handleOnrampError) } @@ -228,24 +230,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() } } @@ -257,8 +259,8 @@ internal class OnrampV2MainComponentModel @Inject constructor( ifLeft = ::handleOnrampError, ifRight = { country -> if (country == null) return@onEach - _state.update { - when (it) { + state.update { prevState -> + when (prevState) { is OnrampV2MainComponentUM.Content -> { amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } @@ -288,94 +290,50 @@ 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) } + } + quotes.none { it is OnrampQuote.Data } -> { + state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } + } + else -> { + state.update { amountStateFactory.getAmountSecondaryFieldResetState() } } } - newQuote?.let { updateProvider(newQuote) } - - return newQuote } private fun onRetryQuotes() { - _state.update { - (it as? OnrampV2MainComponentUM.Content)?.copy( + state.update { prevState -> + (prevState 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 = prevState.amountBlockState.copy( + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty, ), - ) ?: it + ) ?: prevState } 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() }, ) - startLoadingQuotes() } private fun handleOnrampError(onrampError: OnrampError) { Timber.e(onrampError.toString()) - _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) - } + state.update { stateFactory.getOnrampErrorState(onrampError) } } private fun sendOnrampQuotesErrorAnalytic(quotes: List) { @@ -398,11 +356,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 +365,33 @@ internal class OnrampV2MainComponentModel @Inject constructor( ) } + private fun sendOfferClickEvent( + quote: OnrampProviderWithQuote.Data, + onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM, + categoryUM: OnrampOfferCategoryUM, + ) { + val event = when (categoryUM) { + OnrampOfferCategoryUM.RecentlyUsed -> { + OnrampAnalyticsEvent.RecentlyBuyClicked( + tokenSymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethod = quote.paymentMethod.name, + ) + } + OnrampOfferCategoryUM.Recommended -> { + onrampOfferAdvantagesUM.toAnalyticsEvent( + cryptoCurrencySymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethodName = quote.paymentMethod.name, + ) + } + } + + if (event != null) { + analyticsEventHandler.send(event) + } + } + 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..e67a1fbc85 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,61 +14,39 @@ 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 @Composable -internal fun OnrampFooterContent( - state: OnrampV2MainComponentUM.Content, - boxScope: BoxScope, - modifier: Modifier = Modifier, -) { - val keyboardController = LocalSoftwareKeyboardController.current - - boxScope.apply { - AnimatedVisibility( - modifier = Modifier - .imePadding() - .align(Alignment.BottomCenter), - visible = state.offersBlockState.isBlockVisible.not(), - enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = tween(durationMillis = 300), - ), - exit = slideOutVertically( - targetOffsetY = { it }, - animationSpec = tween(durationMillis = 300), - ), - label = "Footer block animation", +internal fun BoxScope.OnrampFooterContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) { + AnimatedVisibility( + modifier = modifier + .imePadding() + .align(Alignment.BottomCenter), + visible = state.offersBlockState is OnrampOffersBlockUM.Empty, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 300), + ), + label = "Footer block animation", + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - 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) - } + 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..7e9499e37d 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 @@ -60,10 +60,7 @@ internal fun OnrampNewMainComponentContent(state: OnrampV2MainComponentUM, modif } if (state is OnrampV2MainComponentUM.Content) { - OnrampFooterContent( - state = state, - boxScope = this, - ) + OnrampFooterContent(state = state) } } } @@ -73,7 +70,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..3c75e311cc 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,25 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { } } - state.onrampAllOffersButtonConfig?.let { + if (state.onrampAllOffersButtonConfig != null) { SpacerH(12.dp) SecondaryButton( modifier = Modifier.fillMaxWidth(), - text = it.title.resolveReference(), - onClick = it.onClick, + text = state.onrampAllOffersButtonConfig.title.resolveReference(), + onClick = state.onrampAllOffersButtonConfig.onClick, ) } } } + OnrampOffersBlockUM.Loading -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + LoadingBlock() + } + } + OnrampOffersBlockUM.Empty -> Unit } } @@ -120,13 +118,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 +145,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 +180,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_provider_min_amount + OnrampOfferAdvantagesUM.Unavailable.MaxAmount -> R.string.onramp_provider_max_amount } + 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,10 +209,11 @@ 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 { + + if (diff != null) { Text( modifier = Modifier .background( @@ -208,7 +221,7 @@ private fun RateBlock(rate: String, diff: TextReference?) { shape = RoundedCornerShape(4.dp), ) .padding(horizontal = 4.dp), - text = it.resolveReference(), + text = diff.resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.warning, ) @@ -314,7 +327,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 +343,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 +377,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 +386,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 +394,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 +415,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..8cc5c7aca6 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() @@ -138,28 +135,15 @@ private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) { top = TangemTheme.dimens.spacing8, start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, - ) - .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/providers/entity/ProviderListPaymentMethodUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/entity/ProviderListPaymentMethodUM.kt index 08d293987e..da978c53f9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/entity/ProviderListPaymentMethodUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/entity/ProviderListPaymentMethodUM.kt @@ -4,6 +4,6 @@ internal data class ProviderListPaymentMethodUM( val id: String, val name: String, val imageUrl: String, - val enabled: Boolean, + val isEnabled: Boolean, val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/SelectProviderModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/SelectProviderModel.kt index 126eedcb3a..7c5493ae04 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/SelectProviderModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/SelectProviderModel.kt @@ -36,7 +36,6 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -55,10 +54,11 @@ internal class SelectProviderModel @Inject constructor( paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = _state.asStateFlow() + val state: StateFlow + field = MutableStateFlow(getInitialState()) + val bottomSheetNavigation: SlotNavigation = SlotNavigation() private val params: SelectProviderComponent.Params = paramsContainer.require() - private val _state = MutableStateFlow(getInitialState()) private var userCountry: UserCountry? = null @@ -84,18 +84,18 @@ internal class SelectProviderModel @Inject constructor( val filteredEmptyMethods = methods.mapNotNull { method -> val providers = getOnrampProviderWithQuoteUseCase(method).getOrNull() if (!providers.isNullOrEmpty()) { - val allErrorProviders = providers.all { + val isAllErrorProviders = providers.all { it is OnrampProviderWithQuote.Unavailable.NotSupportedPaymentMethod } PaymentProviderUM( paymentMethod = method, providers = providers.toProvidersListItems(), - ).takeIf { !allErrorProviders } + ).takeIf { !isAllErrorProviders } } else { null } } - _state.value = state.value.copy( + state.value = state.value.copy( paymentMethods = filteredEmptyMethods.toPersistentList(), isPaymentMethodClickEnabled = filteredEmptyMethods.isNotEmpty(), ) @@ -106,7 +106,7 @@ internal class SelectProviderModel @Inject constructor( modelScope.launch { getOnrampProviderWithQuoteUseCase.invoke(paymentMethod) .onRight { quotes -> - _state.update { state -> + state.update { state -> state.copy( selectedPaymentMethod = state.selectedPaymentMethod.copy( providers = quotes.toProvidersListItems(), @@ -163,7 +163,7 @@ internal class SelectProviderModel @Inject constructor( val firstProvider = methodContainer.providers.firstOrNull() analyticsEventHandler.send(OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = paymentMethod.name)) modelScope.launch { - _state.update { state -> + state.update { state -> state.copy( selectedPaymentMethod = state.selectedPaymentMethod.copy( paymentMethod = paymentMethod, @@ -279,7 +279,7 @@ internal class SelectProviderModel @Inject constructor( tokenSymbol = params.cryptoCurrency.symbol, ), ) - _state.update { + state.update { it.copy(selectedProviderId = result.provider.id) } params.onProviderClick(result, isBestRate) @@ -303,15 +303,15 @@ internal class SelectProviderModel @Inject constructor( * 1. Highest rate * 2. Smallest difference between entered amount and required min/max amount */ - private fun List.sortByRate() = sortedByDescending { - when (it) { - is OnrampProviderWithQuote.Data -> it.toAmount.value + private fun List.sortByRate() = sortedByDescending { quote -> + when (quote) { + is OnrampProviderWithQuote.Data -> quote.toAmount.value // negative difference to sort both when data and unavailable is present is OnrampProviderWithQuote.Unavailable.AmountError -> { - when (val error = it.quoteError.error) { - is OnrampError.AmountError.TooSmallError -> it.quoteError.fromAmount.value - error.requiredAmount - is OnrampError.AmountError.TooBigError -> error.requiredAmount - it.quoteError.fromAmount.value + when (val error = quote.quoteError.error) { + is OnrampError.AmountError.TooSmallError -> quote.quoteError.fromAmount.value - error.requiredAmount + is OnrampError.AmountError.TooBigError -> error.requiredAmount - quote.quoteError.fromAmount.value else -> null } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/previewData/SelectProviderPreviewData.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/previewData/SelectProviderPreviewData.kt index f4a23e8734..6d1a3d1c1f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/previewData/SelectProviderPreviewData.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/model/previewData/SelectProviderPreviewData.kt @@ -27,7 +27,7 @@ internal object SelectProviderPreviewData { termsOfUseLink = null, privacyPolicyLink = null, ), - paymentMethods = listOf(), + paymentMethods = emptyList(), ) private val providerListUM = PaymentProviderUM( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt index 01c26e0e76..c803a99746 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/redirect/model/OnrampRedirectModel.kt @@ -83,14 +83,14 @@ internal class OnrampRedirectModel @Inject constructor( isDarkTheme = isDarkTheme, ) .onLeft(::handleError) - .onRight { - latestOnrampTransaction = it + .onRight { transaction -> + latestOnrampTransaction = transaction // Workaround to open Unlimit provider in external browser instead of chrome custom tabs if (params.onrampProviderWithQuote.provider.id.equals(UNLIMIT_PROVIDER_ID, ignoreCase = true)) { - urlOpener.openUrlExternalBrowser(it.redirectUrl) + urlOpener.openUrlExternalBrowser(transaction.redirectUrl) } else { - urlOpener.openUrl(it.redirectUrl) + urlOpener.openUrl(transaction.redirectUrl) } } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt index 6e28350998..1133a72cfc 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt @@ -47,6 +47,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor( childFactory = ::screenChild, ) + @Suppress("ReusedModifierInstance") @Composable override fun Content(modifier: Modifier) { val childStack by contentStack.subscribeAsState() @@ -78,10 +79,10 @@ internal class DefaultOnrampComponent @AssistedInject constructor( cryptoCurrency = params.cryptoCurrency, openSettings = { navigation.push(OnrampChild.Settings) }, source = params.source, - openRedirectPage = { + openRedirectPage = { quote -> navigation.push( OnrampChild.RedirectPage( - quote = it, + quote = quote, cryptoCurrency = params.cryptoCurrency, ), ) @@ -96,15 +97,15 @@ internal class DefaultOnrampComponent @AssistedInject constructor( cryptoCurrency = params.cryptoCurrency, openSettings = { navigation.push(OnrampChild.Settings) }, source = params.source, - openRedirectPage = { + openRedirectPage = { onrampProviderWithQuoteData -> navigation.push( OnrampChild.RedirectPage( - quote = it, + quote = onrampProviderWithQuoteData, cryptoCurrency = params.cryptoCurrency, ), ) }, - launchSepa = params.shouldLaunchSepa, + isLaunchSepa = params.shouldLaunchSepa, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/entity/CountryListUMController.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/entity/CountryListUMController.kt index 94114f2c2a..624267e18d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/entity/CountryListUMController.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/entity/CountryListUMController.kt @@ -6,7 +6,6 @@ import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update internal class CountryListUMController( @@ -14,16 +13,17 @@ internal class CountryListUMController( loadingItems: ImmutableList, ) { - val state: StateFlow get() = _state.asStateFlow() - private val _state: MutableStateFlow = - MutableStateFlow(value = CountryListUM.Loading(searchBarUM = searchBarUM, items = loadingItems)) + val state: StateFlow + field = MutableStateFlow( + value = CountryListUM.Loading(searchBarUM = searchBarUM, items = loadingItems), + ) fun update(transformer: Transformer) { - _state.update(transformer::transform) + state.update(transformer::transform) } fun update(transformer: SearchBarUMTransformer) { - _state.update { prevState -> + state.update { prevState -> val searchBarUM = transformer.transform(prevState.searchBarUM) prevState.copySealed(searchBarUM) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt index a30217c087..8c22aeb96a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt @@ -52,13 +52,13 @@ internal class OnrampSelectCountryModel @Inject constructor( private val params: SelectCountryComponent.Params = paramsContainer.require() private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } - val state: StateFlow get() = controller.state - private val controller = CountryListUMController( searchBarUM = createSearchBarUM(), loadingItems = loadingItems, ) + val state: StateFlow get() = controller.state + init { analyticsEventHandler.send(OnrampAnalyticsEvent.SelectResidenceOpened) updateCountriesList() @@ -69,7 +69,7 @@ internal class OnrampSelectCountryModel @Inject constructor( params.onDismiss(isCountrySelected) } - private suspend fun subscribeOnUpdateState() { + private fun subscribeOnUpdateState() { combine( flow = getOnrampCountriesUseCase(), flow2 = getOnrampCountryUseCase(), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/entity/CurrencyListController.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/entity/CurrencyListController.kt index eaad4b3365..0e4d04965a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/entity/CurrencyListController.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/entity/CurrencyListController.kt @@ -6,27 +6,26 @@ import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update internal class CurrencyListController( currencySearchBarUM: SearchBarUM, loadingSections: ImmutableList>, ) { - val state: StateFlow get() = _state.asStateFlow() - private val _state: MutableStateFlow = MutableStateFlow( - value = CurrenciesListUM.Loading( - searchBarUM = currencySearchBarUM, - sections = loadingSections, - ), - ) + val state: StateFlow + field = MutableStateFlow( + value = CurrenciesListUM.Loading( + searchBarUM = currencySearchBarUM, + sections = loadingSections, + ), + ) fun update(transformer: Transformer) { - _state.update(transformer::transform) + state.update(transformer::transform) } fun update(transformer: SearchBarUMTransformer) { - _state.update { prevState -> + state.update { prevState -> val searchBarUM = transformer.transform(prevState.searchBarUM) prevState.copySealed(searchBarUM) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt index c7899de0de..39bac756fa 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt @@ -46,14 +46,14 @@ internal class OnrampSelectCurrencyModel @Inject constructor( paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = controller.state - - private val params: SelectCurrencyComponent.Params = paramsContainer.require() private val controller = CurrencyListController( currencySearchBarUM = createSearchBarUM(), loadingSections = loadingSections, ) + val state: StateFlow get() = controller.state + private val params: SelectCurrencyComponent.Params = paramsContainer.require() + init { updateCurrenciesList() subscribeOnUpdateState() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt index 890446c68e..a8957a1d0c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt @@ -49,12 +49,11 @@ internal class OnrampOperationModel @Inject constructor( private val rampStateManager: RampStateManager, ) : Model() { - val state: StateFlow get() = _state + val state: StateFlow + field = MutableStateFlow(value = getInitialState()) private val params: Params = paramsContainer.require() - private val _state = MutableStateFlow(value = getInitialState()) - private val selectedUserWallet = getWalletsUseCase.invokeSync() .first { it.walletId == params.userWalletId } @@ -176,7 +175,7 @@ internal class OnrampOperationModel @Inject constructor( fun onTokenListInitialized() { // Makes HotCrypto tokens visible when token list is initialized to synchronize UI - _state.update { + state.update { it.copy(isHotCryptoVisible = true) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/model/OnrampSettingsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/model/OnrampSettingsModel.kt index 95ed639744..9fe53c12a0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/model/OnrampSettingsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/settings/model/OnrampSettingsModel.kt @@ -29,11 +29,12 @@ internal class OnrampSettingsModel @Inject constructor( paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = _state + val state: StateFlow + field = MutableStateFlow(getInitialState()) + val bottomSheetNavigation: SlotNavigation = SlotNavigation() private val params: OnrampSettingsComponent.Params = paramsContainer.require() - private val _state: MutableStateFlow = MutableStateFlow(getInitialState()) init { analyticsEventHandler.send(OnrampAnalyticsEvent.SettingsOpened) @@ -52,7 +53,7 @@ internal class OnrampSettingsModel @Inject constructor( analyticsEventHandler.sendOnrampErrorEvent(it, params.cryptoCurrency.symbol) }, ifRight = { country -> - _state.update { state -> + state.update { state -> state.copy( items = listOf( OnrampSettingsItemUM.Residence( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt index 7aab8d8e43..ad9f3c8d31 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt @@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.onramp.GetOnrampStatusUseCase import com.tangem.domain.onramp.GetOnrampTransactionUseCase import com.tangem.domain.onramp.OnrampRemoveTransactionUseCase @@ -21,7 +22,6 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.tokens.GetCryptoCurrencyUseCase import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.onramp.component.OnrampSuccessComponent import com.tangem.features.onramp.impl.R @@ -32,6 +32,7 @@ import com.tangem.features.onramp.utils.sendOnrampErrorEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.PeriodicTask import com.tangem.utils.coroutines.SingleTaskScheduler +import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -66,7 +67,7 @@ internal class OnrampSuccessComponentModel @Inject constructor( private var userWallet: UserWallet by Delegates.notNull() private var cryptoCurrency: CryptoCurrency by Delegates.notNull() - private var expressTxStatusTaskScheduler = SingleTaskScheduler() + private val expressTxStatusTaskScheduler = SingleTaskScheduler() init { loadData() @@ -119,7 +120,7 @@ internal class OnrampSuccessComponentModel @Inject constructor( isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { - runCatching { + runSuspendCatching { loadTransactionStatus(transaction) } }, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index 8e19216bf3..d862519a45 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -44,6 +44,7 @@ import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -68,7 +69,7 @@ internal class AvailableSwapPairsModel @Inject constructor( val state: StateFlow = tokenListUMController.state - private var params: AvailableSwapPairsComponent.Params = paramsContainer.require() + private val params: AvailableSwapPairsComponent.Params = paramsContainer.require() private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val tokenListFlow = getTokenListUseCaseFlow() @@ -101,9 +102,12 @@ internal class AvailableSwapPairsModel @Inject constructor( private fun getAccountListUseCaseFlow(): SharedFlow> { return singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(params.userWalletId)) .distinctUntilChanged() - .map { accountStatusList -> - accountStatusList.accountStatuses.toList() - }.flowOn(dispatchers.default) + .mapNotNull { accountStatusList -> + accountStatusList.accountStatuses.filter { + it is AccountStatus.CryptoPortfolio && it.tokenList !is TokenList.Empty + } + } + .flowOn(dispatchers.default) .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) } @@ -137,9 +141,9 @@ internal class AvailableSwapPairsModel @Inject constructor( availablePairs = pairs, ) }, - ifError = { + ifError = { throwable -> handleErrorState( - cause = it, + cause = throwable, networkInfo = params.selectedStatus.value?.toLeastTokenInfo(), currencies = currencies, ) @@ -181,9 +185,9 @@ internal class AvailableSwapPairsModel @Inject constructor( isAccountsMode = isAccountsMode, ) }, - ifError = { + ifError = { throwable -> handleErrorStateV2( - cause = it, + cause = throwable, networkInfo = params.selectedStatus.value?.toLeastTokenInfo(), accountList = accountList, ) @@ -215,7 +219,7 @@ internal class AvailableSwapPairsModel @Inject constructor( isBalanceHidden = isBalanceHidden, unavailableTokensHeaderReference = resourceReference( id = R.string.tokens_list_unavailable_to_swap_header, - wrappedList(selectedStatus?.currency?.name?.capitalize() ?: ""), + wrappedList(selectedStatus?.currency?.name?.capitalize().orEmpty()), ), ) } @@ -239,7 +243,7 @@ internal class AvailableSwapPairsModel @Inject constructor( isBalanceHidden = isBalanceHidden, unavailableTokensHeaderReference = resourceReference( id = R.string.tokens_list_unavailable_to_swap_header, - wrappedList(selectedStatus?.currency?.name?.capitalize() ?: ""), + wrappedList(selectedStatus?.currency?.name?.capitalize().orEmpty()), ), ) } @@ -255,13 +259,22 @@ internal class AvailableSwapPairsModel @Inject constructor( ): TokenListUMTransformer { val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding - val filterByQueryAccountList = accountList.associate { accountStatus -> - when (accountStatus) { - is AccountStatus.CryptoPortfolio -> accountStatus.account to accountStatus.tokenList.flattenCurrencies() - .filter { it.currency != selectedStatus?.currency } - .filterByQuery(query = query) + val filterByQueryAccountList = accountList + .associate { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> { + val statuses = accountStatus.tokenList.flattenCurrencies() + .filterNot { status -> + status.currency.network.backendId == selectedStatus?.currency?.network?.backendId && + status.currency.id.contractAddress == selectedStatus.currency.id.contractAddress + } + .filterByQuery(query = query) + + accountStatus.account to statuses + } + } } - } + .filterValues { it.isNotEmpty() } if (availablePairs.isEmpty()) { return SetNoAvailablePairsTransformerV2( @@ -357,7 +370,7 @@ internal class AvailableSwapPairsModel @Inject constructor( } private suspend fun updateAvailablePairs(networkInfo: LeastTokenInfo, statuses: List) { - runCatching { + runSuspendCatching { availablePairsByNetworkFlow.update(networkInfo = networkInfo, state = lceLoading()) getAvailablePairsUseCase( @@ -378,9 +391,9 @@ internal class AvailableSwapPairsModel @Inject constructor( networkInfo: LeastTokenInfo, state: AvailablePairsState, ) { - update { - it.toMutableMap().apply { - put(networkInfo, state) + update { map -> + map.toMutableMap().apply { + this[networkInfo] = state } } } @@ -421,9 +434,9 @@ internal class AvailableSwapPairsModel @Inject constructor( } private fun List.filterByQuery(query: String): List { - return filter { - it.currency.name.contains(other = query, ignoreCase = true) || - it.currency.symbol.contains(other = query, ignoreCase = true) + return filter { status -> + status.currency.name.contains(other = query, ignoreCase = true) || + status.currency.symbol.contains(other = query, ignoreCase = true) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt index f2fe285085..616f0761c4 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt @@ -15,24 +15,23 @@ import javax.inject.Inject */ internal class SwapSelectTokensController @Inject constructor() { - val state: StateFlow get() = _state - - private val _state: MutableStateFlow = MutableStateFlow( - value = SwapSelectTokensUM( - onBackClick = {}, - exchangeFrom = createEmptyExchangeFrom(), - exchangeTo = createEmptyExchangeTo(), - isBalanceHidden = false, - ), - ) + val state: StateFlow + field = MutableStateFlow( + value = SwapSelectTokensUM( + onBackClick = {}, + exchangeFrom = createEmptyExchangeFrom(), + exchangeTo = createEmptyExchangeTo(), + isBalanceHidden = false, + ), + ) fun update(transform: (SwapSelectTokensUM) -> SwapSelectTokensUM) { Timber.d("Applying non-name transformation") - _state.update(transform) + state.update(transform) } fun update(transformer: SwapSelectTokensUMTransformer) { - Timber.d("Applying ${transformer::class.simpleName}") - _state.update(transformer::transform) + Timber.d("Applying ${transformer::class.simpleName ?: "null"}") + state.update(transformer::transform) } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index 9bda5e4153..49a9b6d86d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -12,7 +12,6 @@ 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.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.onramp.component.SwapSelectTokensComponent import com.tangem.features.onramp.swap.entity.SwapSelectTokensController @@ -43,15 +42,14 @@ internal class SwapSelectTokensModel @Inject constructor( val state: StateFlow = controller.state - val fromCurrencyStatus: StateFlow get() = _fromCurrencyStatus + val fromCurrencyStatus: StateFlow + field = MutableStateFlow(value = null) - private val _fromCurrencyStatus = MutableStateFlow(value = null) private val _toCurrencyStatus = MutableStateFlow(value = null) private val params = paramsContainer.require() private var isAccountsMode: Boolean = false - private var account: Account.CryptoPortfolio? = null init { controller.update { it.copy(onBackClick = ::onBackClick) } @@ -71,7 +69,7 @@ internal class SwapSelectTokensModel @Inject constructor( event = MainScreenAnalyticsEvent.SwapTokenClicked(currencySymbol = status.currency.symbol), ) - _fromCurrencyStatus.value = status + fromCurrencyStatus.value = status modelScope.launch { controller.update( @@ -173,7 +171,7 @@ internal class SwapSelectTokensModel @Inject constructor( } private fun onRemoveFromTokenClick() { - val currencySymbol = requireNotNull(_fromCurrencyStatus.value?.currency?.symbol) { + val currencySymbol = requireNotNull(fromCurrencyStatus.value?.currency?.symbol) { "Token was not selected" } @@ -185,7 +183,7 @@ internal class SwapSelectTokensModel @Inject constructor( } private fun removeSelectedFromToken() { - _fromCurrencyStatus.value = null + fromCurrencyStatus.value = null controller.update(transformer = RemoveSelectedFromTokenTransformer) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt index 583697cca2..5fb7287648 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt @@ -18,37 +18,36 @@ import javax.inject.Inject */ internal class TokenListUMController @Inject constructor() { - val state: StateFlow get() = _state - - private val _state: MutableStateFlow = MutableStateFlow( - value = TokenListUM( - searchBarUM = SearchBarUM( - placeholderText = resourceReference(id = R.string.common_search), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = {}, + val state: StateFlow + field = MutableStateFlow( + value = TokenListUM( + searchBarUM = SearchBarUM( + placeholderText = resourceReference(id = R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + ), + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, + isBalanceHidden = false, ), - availableItems = persistentListOf(), - unavailableItems = persistentListOf(), - tokensListData = TokenListUMData.EmptyList, - isBalanceHidden = false, - ), - ) + ) fun update(transform: (TokenListUM) -> TokenListUM) { Timber.d("Applying non-name transformation") - _state.update(transform) + state.update(transform) } fun update(transformer: TokenListUMTransformer) { - Timber.d("Applying ${transformer::class.simpleName}") - _state.update(transformer::transform) + Timber.d("Applying ${transformer::class.simpleName ?: "unknown"}") + state.update(transformer::transform) } fun update(transformer: SearchBarUMTransformer) { - Timber.d("Applying ${transformer::class.simpleName}") - _state.update { prevState -> + Timber.d("Applying ${transformer::class.simpleName ?: "unknown"}") + state.update { prevState -> prevState.copy( searchBarUM = transformer.transform(prevState.searchBarUM), ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt index fcaa9d6147..1b388e9a47 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt @@ -8,7 +8,6 @@ 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 kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList internal class SetNothingToFoundStateTransformer( private val isBalanceHidden: Boolean, @@ -17,17 +16,15 @@ internal class SetNothingToFoundStateTransformer( override fun transform(prevState: TokenListUM): TokenListUM { return prevState.copy( - availableItems = buildList { + availableItems = persistentListOf( createGroupTitle( textReference = resourceReference(id = R.string.exchange_tokens_available_tokens_header), - ) - .let(::add) - + ), TokensListItemUM.Text( id = emptySearchMessageReference.hashCode(), text = emptySearchMessageReference, - ).let(::add) - }.toImmutableList(), + ), + ), unavailableItems = persistentListOf(), tokensListData = TokenListUMData.EmptyList, isBalanceHidden = isBalanceHidden, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt index dafa2ac842..ddc0a2ac1f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt @@ -6,7 +6,6 @@ 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 kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList internal class SetNothingToFoundStateTransformerV2( private val isBalanceHidden: Boolean, @@ -17,12 +16,14 @@ internal class SetNothingToFoundStateTransformerV2( return prevState.copy( availableItems = persistentListOf(), unavailableItems = persistentListOf(), - tokensListData = TokenListUMData.TokenList(tokensList = buildList { - TokensListItemUM.Text( - id = emptySearchMessageReference.hashCode(), - text = emptySearchMessageReference, - ).let(::add) - }.toImmutableList()), + tokensListData = TokenListUMData.TokenList( + tokensList = persistentListOf( + TokensListItemUM.Text( + id = emptySearchMessageReference.hashCode(), + text = emptySearchMessageReference, + ), + ), + ), isBalanceHidden = isBalanceHidden, ) } 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/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt index 67f0c13e19..44bf47cd76 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt @@ -22,11 +22,11 @@ internal object OnrampTokenItemStateConverterFactory { ): TokenItemStateConverter { return TokenItemStateConverter( appCurrency = appCurrency, - subtitleStateProvider = { + subtitleStateProvider = { status -> createSubtitleState( - status = it, + status = status, isAvailable = true, - text = stringReference(value = it.currency.symbol), + text = stringReference(value = status.currency.symbol), ) }, subtitle2StateProvider = ::createSubtitle2State, @@ -41,16 +41,16 @@ internal object OnrampTokenItemStateConverterFactory { return TokenItemStateConverter( appCurrency = appCurrency, iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) }, - titleStateProvider = { + titleStateProvider = { status -> TokenItemState.TitleState.Content( - text = stringReference(value = it.currency.name), + text = stringReference(value = status.currency.name), isAvailable = false, ) }, - subtitleStateProvider = { + subtitleStateProvider = { status -> createSubtitleState( - status = it, - text = stringReference(value = it.currency.symbol), + status = status, + text = stringReference(value = status.currency.symbol), isAvailable = false, ) }, @@ -68,15 +68,15 @@ internal object OnrampTokenItemStateConverterFactory { return TokenItemStateConverter( appCurrency = appCurrency, iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) }, - titleStateProvider = { + titleStateProvider = { status -> TokenItemState.TitleState.Content( - text = stringReference(value = it.currency.name), + text = stringReference(value = status.currency.name), isAvailable = false, ) }, - subtitleStateProvider = { + subtitleStateProvider = { status -> createSubtitleState( - status = it, + status = status, isAvailable = false, text = unavailableErrorText, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenListUMExt.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenListUMExt.kt index 27c3500039..e283bf38b8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenListUMExt.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenListUMExt.kt @@ -10,7 +10,7 @@ fun List.addHeader(textReference: TextReference): ImmutableLis return buildList { if (items.isNotEmpty()) { - createGroupTitle(textReference = textReference).let(::add) + add(createGroupTitle(textReference = textReference)) } addAll(items) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index c8f65cda5a..e36eabad90 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -5,6 +5,7 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.InputManager +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.models.AccountStatusList @@ -120,11 +121,11 @@ internal class OnrampTokenListModel @Inject constructor( UpdateTokenItemsTransformer( appCurrency = appCurrency, onItemClick = params.onTokenClick, - statuses = filterByQueryTokenList.let { + statuses = filterByQueryTokenList.let { statuses -> if (hasRestrictionForSell || isInsufficientBalanceForSell) { - mapOf(false to it) + mapOf(false to statuses) } else { - it.filterByAvailability() + statuses.filterByAvailability() } }, isBalanceHidden = isBalanceHidden, @@ -226,17 +227,25 @@ internal class OnrampTokenListModel @Inject constructor( } } - private fun getUnavailableTokensHeaderReference() = when (params.filterOperation) { - OnrampOperation.BUY -> R.string.tokens_list_unavailable_to_purchase_header - OnrampOperation.SELL -> R.string.tokens_list_unavailable_to_sell_header - OnrampOperation.SWAP -> R.string.tokens_list_unavailable_to_swap_source_header - }.let(::resourceReference) + private fun getUnavailableTokensHeaderReference(): TextReference { + val res = when (params.filterOperation) { + OnrampOperation.BUY -> R.string.tokens_list_unavailable_to_purchase_header + OnrampOperation.SELL -> R.string.tokens_list_unavailable_to_sell_header + OnrampOperation.SWAP -> R.string.tokens_list_unavailable_to_swap_source_header + } - private fun getEmptySearchMessageReference() = when (params.filterOperation) { - OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message - OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message - OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message - }.let(::resourceReference) + return resourceReference(res) + } + + private fun getEmptySearchMessageReference(): TextReference { + val res = when (params.filterOperation) { + OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message + OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message + OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message + } + + return resourceReference(res) + } private fun updateTokenListUM(transformer: TokenListUMTransformer) { modelScope.launch { @@ -297,9 +306,9 @@ internal class OnrampTokenListModel @Inject constructor( }.filter { (_, value) -> value.isNotEmpty() } private fun List.filterByQuery(query: String): List { - return filter { - it.currency.name.contains(other = query, ignoreCase = true) || - it.currency.symbol.contains(other = query, ignoreCase = true) + return filter { status -> + status.currency.name.contains(other = query, ignoreCase = true) || + status.currency.symbol.contains(other = query, ignoreCase = true) } } diff --git a/features/qr-scanning/impl/detekt-baseline-debug.xml b/features/qr-scanning/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d8aaeb807c --- /dev/null +++ b/features/qr-scanning/impl/detekt-baseline-debug.xml @@ -0,0 +1,17 @@ + + + + + CanBeNonNullable:MLKitBarcodeAnalyzer.kt$MLKitBarcodeAnalyzer$onClose: (() -> Unit)? = null + MultilineLambdaItParameter:DefaultQrScanningComponent.kt$DefaultQrScanningComponent${ val selectedImage = it ?: Uri.EMPTY if (selectedImage != Uri.EMPTY) { val mimeType = context.contentResolver.getType(selectedImage) if (mimeType.isImageMimeType()) { try { val image = InputImage.fromFilePath(context, selectedImage) analyzer.analyze(image) } catch (e: IOException) { Timber.e(e, "Unable to get image $selectedImage from gallery") } } } } + MultilineLambdaItParameter:QrScanningContent.kt${ TopAppBarButton( button = TopAppBarButtonUM.Icon( iconRes = if (it) R.drawable.ic_flash_on_24 else R.drawable.ic_flash_off_24, onClicked = { isFlash = !isFlash }, ), tint = TangemColorPalette.White, modifier = Modifier.testTag(QrCodeScreenTestTags.FLASHLIGHT_BUTTON), ) } + MultilineLambdaItParameter:QrScanningContent.kt${ textMeasurer.measure( text = it, style = style, constraints = Constraints.fixedWidth(squareSize.roundToInt()), ) } + NestedScopeFunctions:MLKitBarcodeAnalyzer.kt$MLKitBarcodeAnalyzer$let { onScanned.invoke(it) } + NestedScopeFunctions:QrScanningContent.kt$let { textMeasurer.measure( text = it, style = style, constraints = Constraints.fixedWidth(squareSize.roundToInt()), ) } + NoNameShadowing:QrScanningContent.kt${ textMeasurer.measure( text = it, style = style, constraints = Constraints.fixedWidth(squareSize.roundToInt()), ) } + NoNameShadowing:QrScanningModel.kt$QrScanningModel$qrCode + PropertyUsedBeforeDeclaration:QrScanningStateController.kt$QrScanningStateController$mutableUiState + UnnecessaryLet:QrScanningContent.kt$let { textMeasurer.measure( text = it, style = style, constraints = Constraints.fixedWidth(squareSize.roundToInt()), ) } + UnnecessaryLet:QrScanningContent.kt$let { val textLayoutResult = text.let { textMeasurer.measure( text = it, style = style, constraints = Constraints.fixedWidth(squareSize.roundToInt()), ) } drawText( textLayoutResult = textLayoutResult, color = whiteColor, topLeft = Offset( x = horizontalOffset, y = verticalOffset - textPadding - textLayoutResult.size.height, ), ) } + + diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt index 7e11df9de8..9720a0c74b 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign @@ -32,6 +33,7 @@ import com.tangem.core.ui.extensions.resolveReference 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.test.QrCodeScreenTestTags import com.tangem.feature.qrscanning.impl.R import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer import java.util.concurrent.ExecutorService @@ -87,6 +89,7 @@ internal fun QrScanningContent( }, ), tint = TangemColorPalette.White, + modifier = Modifier.testTag(QrCodeScreenTestTags.FLASHLIGHT_BUTTON), ) } @@ -96,6 +99,7 @@ internal fun QrScanningContent( onClicked = uiState.onGalleryClick, ), tint = TangemColorPalette.White, + modifier = Modifier.testTag(QrCodeScreenTestTags.GALLERY_BUTTON), ) }, ) diff --git a/features/referral/data/build.gradle.kts b/features/referral/data/build.gradle.kts index bbe186db0f..85cd526d9f 100644 --- a/features/referral/data/build.gradle.kts +++ b/features/referral/data/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.referral) implementation(projects.features.referral.domain) /** Libs */ diff --git a/features/referral/data/src/main/java/com/tangem/feature/referral/converters/ReferralConverter.kt b/features/referral/data/src/main/java/com/tangem/feature/referral/converters/ReferralConverter.kt index 921f57b107..6bedb6bbed 100644 --- a/features/referral/data/src/main/java/com/tangem/feature/referral/converters/ReferralConverter.kt +++ b/features/referral/data/src/main/java/com/tangem/feature/referral/converters/ReferralConverter.kt @@ -70,10 +70,10 @@ private class ExpectedAwardsConverter : Converter ExpectedAward( - paymentDate = it.paymentDate.toDateTimeAtStartOfDay().millis.toDateFormat(), - amount = "${it.amount} ${it.currency}", + paymentDate = awardItem.paymentDate.toDateTimeAtStartOfDay().millis.toDateFormat(), + amount = "${awardItem.amount} ${awardItem.currency}", ) }, ) 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..9bc5ac4c5d 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,8 +9,10 @@ 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.domain.referral.ReferralStatus import com.tangem.feature.referral.converters.ReferralConverter import com.tangem.feature.referral.domain.ReferralRepository import com.tangem.feature.referral.domain.models.ReferralData @@ -20,14 +22,15 @@ import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -@Suppress("LongParameterList") +typealias ExternalReferralRepository = com.tangem.domain.referral.ReferralRepository + internal class ReferralRepositoryImpl @Inject constructor( private val referralApi: TangemTechApi, private val referralConverter: ReferralConverter, private val coroutineDispatcher: CoroutineDispatcherProvider, private val userWalletsStore: UserWalletsStore, excludedBlockchains: ExcludedBlockchains, -) : ReferralRepository { +) : ReferralRepository, ExternalReferralRepository { private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains) @@ -47,6 +50,21 @@ internal class ReferralRepositoryImpl @Inject constructor( } } + override suspend fun getReferralStatus(userWalletId: String): ReferralStatus { + val data = getReferralData(userWalletId) + + return ReferralStatus( + isActive = data is ReferralData.ParticipantData, + token = data.tokens.firstOrNull()?.let { tokenData -> + ReferralStatus.Token( + networkId = tokenData.networkId, + contractAddress = tokenData.contractAddress, + ) + }, + address = (data as? ReferralData.ParticipantData)?.referral?.address, + ) + } + override suspend fun isReferralParticipant(userWalletId: UserWalletId): Boolean { val storedReferralData = referralStatus[userWalletId.stringValue] ?: getReferralData(userWalletId.stringValue) return storedReferralData is ReferralData.ParticipantData @@ -74,7 +92,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 +119,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/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt b/features/referral/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt index 153d375e6b..8d2c4cdfd4 100644 --- a/features/referral/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt +++ b/features/referral/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt @@ -4,6 +4,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.feature.referral.converters.ReferralConverter +import com.tangem.feature.referral.data.ExternalReferralRepository import com.tangem.feature.referral.data.ReferralRepositoryImpl import com.tangem.feature.referral.domain.ReferralRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -34,4 +35,22 @@ class ReferralRepositoryModule { excludedBlockchains = excludedBlockchains, ) } + + @Provides + @Singleton + fun provideExternalReferralRepository( + tangemTechApi: TangemTechApi, + referralConverter: ReferralConverter, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + userWalletsStore: UserWalletsStore, + excludedBlockchains: ExcludedBlockchains, + ): ExternalReferralRepository { + return ReferralRepositoryImpl( + referralApi = tangemTechApi, + referralConverter = referralConverter, + coroutineDispatcher = coroutineDispatcherProvider, + userWalletsStore = userWalletsStore, + excludedBlockchains = excludedBlockchains, + ) + } } \ No newline at end of file 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/detekt-baseline-debug.xml b/features/referral/domain/detekt-baseline-debug.xml new file mode 100644 index 0000000000..822bbfee9f --- /dev/null +++ b/features/referral/domain/detekt-baseline-debug.xml @@ -0,0 +1,10 @@ + + + + + MultilineLambdaItParameter:ReferralInteractorImpl.kt$ReferralInteractorImpl${ Timber.e("Failed to derive public keys: $it") throw it.mapToDomainError() } + ObjectExtendsThrowable:ReferralError.kt$ReferralError$SdkError : ReferralError + ObjectExtendsThrowable:ReferralError.kt$ReferralError$UserCancelledException : ReferralError + UselessCallOnNotNull:ReferralInteractorImpl.kt$ReferralInteractorImpl$listOfNotNull(cryptoCurrency) + + 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/detekt-baseline-debug.xml b/features/referral/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d0a4690a1f --- /dev/null +++ b/features/referral/impl/detekt-baseline-debug.xml @@ -0,0 +1,16 @@ + + + + + MultilineLambdaItParameter:AgreementText.kt${ val clickableSpanStyle = requireNotNull(agreementText.spanStyles.getOrNull(1)) if (it in clickableSpanStyle.start..clickableSpanStyle.end) { onClick() } } + MultilineLambdaItParameter:ReferralModel.kt$ReferralModel${ analyticsEventHandler.send(ReferralEvents.ParticipateSuccessful) referralData.value = it } + MultilineLambdaItParameter:ReferralScreen.kt${ // TODO: use StateEvent if (stateHolder.errorSnackbar != null) { TangemSnackbar(data = it, actionOnNewLine = true) } else { CopiedTextSnackbar(it) } } + MultilineLambdaItParameter:ReferralScreen.kt${ ReferralContent( stateHolder = stateHolder, snackbarHostState = snackbarHostState, onAgreementClick = stateHolder.analytics.onAgreementClicked, modifier = Modifier.padding(it), ) } + NamedArguments:ReferralScreen.kt$Text( formatAwardConditionsString( quantity = award, network = networkName, address = if (!address.isNullOrBlank()) " $address" else "", ), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, modifier = Modifier.testTag(ReferralProgramScreenTestTags.INFO_FOR_YOU_TEXT), ) + NonBooleanPropertyPrefixedWithIs:ParticipateBottomBlock.kt$val isExpanded = remember { mutableStateOf(false) } + NonBooleanPropertyPrefixedWithIs:ReferralModel.kt$ReferralModel$private val isDemoCardUseCase: IsDemoCardUseCase + SuspendFunSwallowedCancellation:ReferralModel.kt$ReferralModel$runCatching + UseOrEmpty:ReferralScreen.kt$matchResult.groups[1]?.value ?: "" + VarCouldBeVal:ReferralModel.kt$ReferralModel$private var referralData: MutableStateFlow<ReferralData?> = MutableStateFlow(null) + + 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..8b2ef4b83d 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,9 @@ package com.tangem.feature.referral.models import androidx.annotation.DrawableRes -import com.tangem.common.ui.account.CryptoPortfolioIconUM +import androidx.compose.runtime.Immutable +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( @@ -15,6 +15,7 @@ internal data class ReferralStateHolder( data class HeaderState(val onBackClicked: () -> Unit) + @Immutable sealed interface ReferralInfoContentState { val award: String val networkName: String @@ -23,6 +24,7 @@ internal data class ReferralStateHolder( val accountAward: AccountAward? } + @Immutable sealed interface ReferralInfoState { data class ParticipantContent( override val award: String, @@ -50,6 +52,7 @@ internal data class ReferralStateHolder( data object Loading : ReferralInfoState } + @Immutable data class ErrorSnackbar( val throwable: Throwable, val onOkClicked: () -> Unit, @@ -64,8 +67,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/detekt-baseline-debug.xml b/features/send-v2/api/detekt-baseline-debug.xml new file mode 100644 index 0000000000..6512d3e5ba --- /dev/null +++ b/features/send-v2/api/detekt-baseline-debug.xml @@ -0,0 +1,12 @@ + + + + + BooleanPropertyNaming:FeeSelectorData.kt$FeeSelectorData$val removeSuggestedFee: Boolean = false + BooleanPropertyNaming:SendEntryRoute.kt$SendEntryRoute.ChooseToken$val showSendViaSwapNotification: Boolean + NonBooleanPropertyPrefixedWithIs:SendDestinationComponentParams.kt$SendDestinationComponentParams.DestinationParams$val isBalanceHidingFlow: StateFlow<Boolean> + UseEmptyCounterpart:CommonSendAmountAnalyticEvents.kt$CommonSendAmountAnalyticEvents$mapOf() + UseEmptyCounterpart:CommonSendAnalyticEvents.kt$CommonSendAnalyticEvents$mapOf() + UseEmptyCounterpart:CommonSendFeeAnalyticEvents.kt$CommonSendFeeAnalyticEvents$mapOf() + + 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/detekt-baseline-debug.xml b/features/send-v2/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..c88748c497 --- /dev/null +++ b/features/send-v2/impl/detekt-baseline-debug.xml @@ -0,0 +1,86 @@ + + + + + BooleanPropertyNaming:ConfirmUM.kt$ConfirmUM.Content$val showTapHelp: Boolean + BooleanPropertyNaming:FeeSelectorAlertFactory.kt$FeeSelectorAlertFactory$val showFeeTooHigh = checkAndShowFeeTooHigh(feeSelectorUM, onConfirmClick) + BooleanPropertyNaming:FeeSelectorAlertFactory.kt$FeeSelectorAlertFactory$val showFeeTooLow = checkAndShowFeeTooLow(feeSelectorUM, onConfirmClick) + BooleanPropertyNaming:FeeSelectorModalBottomSheet.kt$val lastItem = index == state.feeItems.size - 1 + BooleanPropertyNaming:FeeSelectorModalBottomSheet.kt$val showDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce + BooleanPropertyNaming:NFTSendAnalyticEvents.kt$NFTSendAnalyticEvents.TransactionScreenOpened$val nonceNotEmpty: Boolean + BooleanPropertyNaming:NFTSendSuccessContent.kt$var visible by remember { mutableStateOf(false) } + BooleanPropertyNaming:SendAnalyticEvents.kt$SendAnalyticEvents.TransactionScreenOpened$val nonceNotEmpty: Boolean + BooleanPropertyNaming:SendAnalyticHelper.kt$SendAnalyticHelper$val blockchainAddressForEns = (sendUM.destinationUM as? DestinationUM.Content)?.addressTextField?.isAddressEns + BooleanPropertyNaming:SendConfirmSuccessContent.kt$var visible by remember { mutableStateOf(false) } + BooleanPropertyNaming:SendEntryPointModel.kt$SendEntryPointModel$val showSendViaSwapNotification = shouldShowNotificationUseCase( NotificationId.SendViaSwapTokenSelectorNotification.key, ) + BooleanPropertyNaming:SendRecipientHistoryListConverter.kt$SendRecipientHistoryListConverter$val notZero = !item.amount.isZero() + BooleanPropertyNaming:TapHelp.kt$var wrappedIsDisplay by remember { mutableStateOf(false) } + CanBeNonNullable:SendDestinationContent.kt$memoField: DestinationTextFieldUM.RecipientMemo? + CanBeNonNullable:SendDestinationModel.kt$SendDestinationModel$type: EnterAddressSource? + MaxChainedCallsOnSameLine:FeeSelectorBlockContent.kt$state.selectedFeeItem.fee.amount.value.format { crypto( symbol = state.selectedFeeItem.fee.amount.currencySymbol, decimals = state.selectedFeeItem.fee.amount.decimals, ).fee(canBeLower = state.feeExtraInfo.isFeeApproximate) } + MultilineLambdaItParameter:DefaultSellRedirectDeepLinkHandler.kt$DefaultSellRedirectDeepLinkHandler${ Timber.e("Error on getting cryptoCurrency: $it") return@launch } + MultilineLambdaItParameter:FeeSelectorCustomFieldConverter.kt$FeeSelectorCustomFieldConverter${ when (it) { is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue( minimumFee = it, customValues = customValues, ) else -> customValues } } + MultilineLambdaItParameter:FeeSelectorModel.kt$FeeSelectorModel${ feeSelectorAlertFactory.getFeeUpdatedAlert( newFee = it, feeSelectorUM = uiState.value, proceedAction = { modelScope.launch { feeSelectorCheckReloadTrigger.callbackCheckResult(true) } }, stopAction = { modelScope.launch { feeSelectorCheckReloadTrigger.callbackCheckResult(false) } }, ) } + MultilineLambdaItParameter:KaspaCustomFeeConverter.kt$KaspaCustomFeeConverter${ val valueDecimal = it.value.parseToBigDecimal(it.decimals) // krc-20 transaction will be failed if custom fee value is less than minimum, // so we set value to minimum in this case if (valueDecimal < minimumFee.amount.value) { val fixedValue = minimumFeeAmountValue.parseBigDecimal(it.decimals) set( FEE_AMOUNT_INDEX, it.copy( value = fixedValue, label = getFiatReference( rate = currencyStatus.fiatRate, value = valueDecimal, appCurrency = appCurrency, ), ), ) } } + MultilineLambdaItParameter:NFTSendConfirmModel.kt$NFTSendConfirmModel${ it.copy( confirmUM = NFTSendConfirmInitialStateTransformer( isShowTapHelp = isShowTapHelp, walletName = stringReference(userWallet.name), ).transform(uiState.value.confirmUM), ) } + MultilineLambdaItParameter:NFTSendConfirmModel.kt$NFTSendConfirmModel${ it.copy( confirmUM = NFTSendConfirmationNotificationsTransformerV2( feeSelectorUM = uiState.value.feeSelectorUM, analyticsEventHandler = analyticsEventHandler, cryptoCurrency = cryptoCurrencyStatus.currency, appCurrency = params.appCurrency, analyticsCategoryName = analyticsCategoryName, ).transform(uiState.value.confirmUM), ) } + MultilineLambdaItParameter:NFTSendConfirmModel.kt$NFTSendConfirmModel${ val confirmUM = it.confirmUM as? ConfirmUM.Content it.copy(confirmUM = confirmUM?.copy(showTapHelp = showTapHelp) ?: it.confirmUM) } + MultilineLambdaItParameter:NFTSendConfirmModel.kt$NFTSendConfirmModel${ val isFeeNotNull = it.feeSelectorUM is FeeSelectorUMRedesigned.Content it.copy( confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy( isPrimaryButtonEnabled = !hasError && isFeeNotNull, ) ?: it.confirmUM, ) } + MultilineLambdaItParameter:RecentListUtils.kt${ add( DestinationRecipientListUM( id = "$tag$it", isLoading = false, isVisible = false, ), ) } + MultilineLambdaItParameter:RecentListUtils.kt${ add( DestinationRecipientListUM( id = "$tag$it", isLoading = true, ), ) } + MultilineLambdaItParameter:SendAmountModel.kt$SendAmountModel${ (it as? AmountState.Data)?.copy( isPrimaryButtonEnabled = false, ) ?: it } + MultilineLambdaItParameter:SendAmountModel.kt$SendAmountModel${ EnterAmountBoundary( amount = it, fiatRate = cryptoCurrencyStatus.value.fiatRate.orZero(), ) } + MultilineLambdaItParameter:SendConfirmModel.kt$SendConfirmModel${ it.copy( confirmUM = SendConfirmInitialStateTransformer( isShowTapHelp = isShowTapHelp, walletName = stringReference(userWallet.name), ).transform(uiState.value.confirmUM), confirmData = confirmData, ) } + MultilineLambdaItParameter:SendConfirmModel.kt$SendConfirmModel${ it.copy( confirmUM = SendConfirmationNotificationsTransformerV2( feeSelectorUM = uiState.value.feeSelectorUM, amountUM = uiState.value.amountUM, analyticsEventHandler = analyticsEventHandler, cryptoCurrency = cryptoCurrencyStatus.currency, appCurrency = appCurrency, analyticsCategoryName = params.analyticsCategoryName, ).transform(uiState.value.confirmUM), ) } + MultilineLambdaItParameter:SendConfirmModel.kt$SendConfirmModel${ val confirmUM = it.confirmUM as? ConfirmUM.Content it.copy(confirmUM = confirmUM?.copy(showTapHelp = showTapHelp) ?: it.confirmUM) } + MultilineLambdaItParameter:SendConfirmModel.kt$SendConfirmModel${ val feeUM = it.feeSelectorUM as? FeeSelectorUMRedesigned.Content it.copy( confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy( isPrimaryButtonEnabled = !hasError && feeUM != null, ) ?: it.confirmUM, ) } + MultilineLambdaItParameter:SendDestinationContent.kt${ if (it) { content() } else { Box(modifier = Modifier.fillMaxWidth()) } } + MultilineLambdaItParameter:SendDestinationModel.kt$SendDestinationModel${ analyticsEventHandler.send( SendDestinationAnalyticEvents.AddressEntered( categoryName = analyticsCategoryName, source = params.analyticsSendSource, method = it, isValid = addressValidationResult.isRight(), ), ) } + MultilineLambdaItParameter:SendDestinationModel.kt$SendDestinationModel${ if (it.network.rawId == cryptoCurrencyNetwork.rawId) { getNetworkAddressesUseCase.invokeSync( userWalletId = wallet.walletId, networkRawId = it.network.id.rawId, ) } else { null } } + MultilineLambdaItParameter:SendDestinationValidationResultTransformer.kt$SendDestinationValidationResultTransformer${ when (it) { is AddressValidation.Error.DataError, AddressValidation.Error.InvalidAddress, -> R.string.send_recipient_address_error AddressValidation.Error.AddressInWallet -> R.string.send_error_address_same_as_wallet } } + MultilineLambdaItParameter:SendModel.kt$SendModel${ Timber.w(it.toString()) showAlertError() return@launch } + MultilineLambdaItParameter:SendModel.kt$SendModel${ it.copy( destinationUM = SendDestinationInitialStateTransformer( cryptoCurrency = cryptoCurrency, ).transform(DestinationUM.Empty()), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, confirmData = null, navigationUM = NavigationUM.Empty, ) } + MultilineLambdaItParameter:SendRecipientWalletListConverter.kt$SendRecipientWalletListConverter${ val isCoin = it.cryptoCurrency is CryptoCurrency.Coin val isNotSameAddress = it.address != senderAddress val isNotBlankAddress = it.address.isNotBlank() isNotBlankAddress && isCoin && (isNotSameAddress || isSelfSendAvailable) } + NamedArguments:EthereumCustomFeeConverter.kt$EthereumCustomFeeConverter$onValueChange(feeValue, customValues, index, value) + NamedArguments:FeeSelectorCustomValueChangedTransformer.kt$FeeSelectorCustomValueChangedTransformer$onValueChange(state, customFee.customValues, index, value) + NestedScopeFunctions:KaspaCustomFeeConverter.kt$KaspaCustomFeeConverter$let { val valueDecimal = it.value.parseToBigDecimal(it.decimals) // krc-20 transaction will be failed if custom fee value is less than minimum, // so we set value to minimum in this case if (valueDecimal < minimumFee.amount.value) { val fixedValue = minimumFeeAmountValue.parseBigDecimal(it.decimals) set( FEE_AMOUNT_INDEX, it.copy( value = fixedValue, label = getFiatReference( rate = currencyStatus.fiatRate, value = valueDecimal, appCurrency = appCurrency, ), ), ) } } + NoNameShadowing:FeeSelectorAlertFactory.kt$FeeSelectorAlertFactory$newFee + NoNameShadowing:SendContent.kt$navigationUM + NonBooleanPropertyPrefixedWithIs:FeeSelectorModel.kt$FeeSelectorModel$private val isFeeApproximateUseCase: IsFeeApproximateUseCase + NonBooleanPropertyPrefixedWithIs:NFTSendConfirmComponent.kt$NFTSendConfirmComponent.Params$val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:NFTSendConfirmModel.kt$NFTSendConfirmModel$private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase + NonBooleanPropertyPrefixedWithIs:NFTSendModel.kt$NFTSendModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:NFTSendModel.kt$NFTSendModel$val isBalanceHiddenFlow: StateFlow<Boolean> field = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:NotificationsModel.kt$NotificationsModel$private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase + NonBooleanPropertyPrefixedWithIs:SendAmountComponentParams.kt$SendAmountComponentParams$abstract val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendAmountComponentParams.kt$SendAmountComponentParams$abstract val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendAmountComponentParams.kt$SendAmountComponentParams.AmountBlockParams$override val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendAmountComponentParams.kt$SendAmountComponentParams.AmountBlockParams$override val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendAmountComponentParams.kt$SendAmountComponentParams.AmountParams$override val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendAmountComponentParams.kt$SendAmountComponentParams.AmountParams$override val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendAmountModel.kt$SendAmountModel$val isSendWithSwapAvailable: StateFlow<Boolean> field = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:SendConfirmComponent.kt$SendConfirmComponent.Params$val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendConfirmComponent.kt$SendConfirmComponent.Params$val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendConfirmModel.kt$SendConfirmModel$private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase + NonBooleanPropertyPrefixedWithIs:SendConfirmModel.kt$SendConfirmModel$private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase + NonBooleanPropertyPrefixedWithIs:SendConfirmModel.kt$SendConfirmModel$val isBalanceHiddenFlow: StateFlow<Boolean> field = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:SendDestinationModel.kt$SendDestinationModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:SendDestinationModel.kt$SendDestinationModel$private val isSelfSendAvailableUseCase: IsSelfSendAvailableUseCase + NonBooleanPropertyPrefixedWithIs:SendModel.kt$SendModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:SendModel.kt$SendModel$val isAccountModeFlow: StateFlow<Boolean> field = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:SendModel.kt$SendModel$val isBalanceHiddenFlow: StateFlow<Boolean> field = MutableStateFlow(false) + NullCheckOnMutableProperty:SendAmountModel.kt$SendAmountModel$if (uiState.value is AmountState.Empty && userWallet != null) { val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1 val walletTitle = if (isOnlyOneWallet) { resourceReference(R.string.send_from_title) } else { resourceReference( R.string.send_from_wallet_name, WrappedList(listOf(userWallet?.name.orEmpty())), // TODO AND-11440 ) } _uiState.update { AmountStateConverter( clickIntents = this, appCurrency = appCurrency, cryptoCurrencyStatus = cryptoCurrencyStatus, maxEnterAmount = maxAmountBoundary, iconStateConverter = CryptoCurrencyToIconStateConverter(), isBalanceHidden = params.isBalanceHidingFlow.value, accountTitleUM = AmountAccountConverter( isAccountsMode = isAccountsMode, walletTitle = walletTitle, prefixText = resourceReference(R.string.common_from), ).convert(account), ).convert( AmountParameters( title = walletTitle, value = "", ), ) } } + NullableToStringCall:BitcoinCustomFeeConverter.kt$BitcoinCustomFeeConverter$toSatoshiPerByte( amount = feeValue, decimals = value.amount.decimals, txSize = value.txSize, ).toString() + NullableToStringCall:NFTSendConfirmModel.kt$NFTSendConfirmModel$params.nftAsset.amount.toString() + ReusedModifierInstance:DefaultSendEntryPointComponent.kt$DefaultSendEntryPointComponent$Content(modifier.fillMaxSize()) + UnnecessaryLet:EthereumCustomFeeConverter.kt$EthereumCustomFeeConverter$let(::add) + UnnecessaryLet:EthereumCustomFeeConverter.kt$EthereumCustomFeeConverter$let(::addAll) + UnnecessaryLet:SendAmountModel.kt$SendAmountModel$let { onCurrencyChangeClick(isEnterInFiat) } + UseEmptyCounterpart:NFTSendAnalyticEvents.kt$NFTSendAnalyticEvents$mapOf() + UseEmptyCounterpart:NotificationsAnalyticEvents.kt$NotificationsAnalyticEvents$mapOf() + UseEmptyCounterpart:SendAnalyticEvents.kt$SendAnalyticEvents$mapOf() + UseEmptyCounterpart:SendDestinationAnalyticEvents.kt$SendDestinationAnalyticEvents$mapOf() + VarCouldBeVal:SendDestinationModel.kt$SendDestinationModel$private var validationJobHolder = JobHolder() + VarCouldBeVal:SendModel.kt$SendModel$private var balanceHidingJobHolder = JobHolder() + + 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..484ac8b89a 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, @@ -102,14 +105,16 @@ private fun ListItemWithIcon( modifier = modifier .fillMaxWidth() .clickable { hapticFeedback() } - .padding(horizontal = 12.dp), + .padding(horizontal = 12.dp) + .testTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM), ) { IdentIcon( address = title, 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 +128,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) { @@ -135,7 +140,8 @@ private fun ListItemWithIcon( .padding(end = 2.dp) .size(16.dp) .background(TangemTheme.colors.background.tertiary, CircleShape) - .padding(2.dp), + .padding(2.dp) + .testTag(SendAddressScreenTestTags.RECENT_ADDRESS_TRANSACTION_ICON), ) } val (text, offset) = remember(subtitle, info) { @@ -151,6 +157,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..96592fdf28 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 @@ -16,18 +16,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle 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 import com.tangem.core.ui.components.inputrow.InputRowRecipient import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM @@ -39,6 +40,7 @@ import kotlinx.collections.immutable.ImmutableList private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY" private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY" +@Suppress("LongMethod") @Composable internal fun SendDestinationContent( state: DestinationUM, @@ -54,7 +56,8 @@ internal fun SendDestinationContent( LazyColumn( modifier = Modifier // Do not put fillMaxSize() in here .background(TangemTheme.colors.background.tertiary) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .testTag(SendAddressScreenTestTags.CONTAINER), ) { addressItem( address = address, @@ -69,18 +72,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 +95,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 +248,6 @@ private fun LazyListScope.listItem( isBalanceHidden: Boolean, isRecentHidden: Boolean, onClick: (String) -> Unit, - accountTitleUM: AccountTitleUM? = null, ) { items( count = list.size, @@ -259,7 +261,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/detekt-baseline-debug.xml b/features/staking/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..920a160906 --- /dev/null +++ b/features/staking/impl/detekt-baseline-debug.xml @@ -0,0 +1,62 @@ + + + + + BooleanPropertyNaming:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer$val showNotification = sendingAmount + feeAmount > balance + BooleanPropertyNaming:AmountCurrencyChangeStateTransformer.kt$AmountCurrencyChangeStateTransformer$private val value: Boolean + BooleanPropertyNaming:StakingModel.kt$StakingModel$val noBalanceState = balanceState == null + BooleanPropertyNaming:StakingModel.kt$StakingModel$val noYieldBalanceData = cryptoCurrencyStatus.value.yieldBalance !is YieldBalance.Data + BooleanPropertyNaming:StakingUiState.kt$StakingStates.InitialInfoState.Data$val showBanner: Boolean + BooleanPropertyNaming:StakingUiState.kt$StakingUiState$val showColdWalletInteractionIcon: Boolean + CanBeNonNullable:StakingScreen.kt$bottomSheetConfig: TangemBottomSheetConfig? + CastNullableToNonNullableType:SetApprovalBottomSheetInProgressTransformer.kt$SetApprovalBottomSheetInProgressTransformer$as + CastNullableToNonNullableType:SetApprovalBottomSheetTypeChangeTransformer.kt$SetApprovalBottomSheetTypeChangeTransformer$as + MultilineLambdaItParameter:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer${ it is StakingNotification.Error || it is NotificationUM.Error || it is NotificationUM.Warning.NetworkFeeUnreachable || it is StakingNotification.Warning.TransactionInProgress || it is StakingNotification.Warning.InitializeTonAccount } + MultilineLambdaItParameter:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler${ val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } + MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.body1, ) } } + MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) } } + MultilineLambdaItParameter:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ onStakingFeeError(it) null } + MultilineLambdaItParameter:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ onStakingFeeError(it) return } + MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.PREPARING || it.type == BalanceType.STAKED || it.type == BalanceType.LOCKED } + MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.STAKED || it.type == BalanceType.PREPARING || it.type == BalanceType.UNSTAKED } + MultilineLambdaItParameter:StakingModel.kt$StakingModel${ EnterAmountBoundary( amount = it, fiatRate = status.value.fiatRate.orZero(), ) } + MultilineLambdaItParameter:StakingModel.kt$StakingModel${ Timber.e(it) false } + MultilineLambdaItParameter:StakingModel.kt$StakingModel${ isBalanceHiddenFlow.value = it.isBalanceHidden stateController.update( transformer = HideBalanceStateTransformer( isBalanceHidden = it.isBalanceHidden, cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrency = appCurrency, ), ) } + MultilineLambdaItParameter:StakingModel.kt$StakingModel${ stateController.update( SetFeeToTonInitializeBottomSheetTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = it.normal, isFeeApproximate = false, ), ) } + MultilineLambdaItParameter:StakingStateController.kt$StakingStateController${ it.copy( showColdWalletInteractionIcon = userWallet is UserWallet.Cold, ) } + MultilineLambdaItParameter:StakingTransactionSender.kt$StakingTransactionSender${ onConstructError(it) null } + MultilineLambdaItParameter:StakingTransactionSender.kt$StakingTransactionSender${ onConstructError(it) return emptyList() } + NoNameShadowing:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader$amount + NoNameShadowing:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ if (!it.amount.isZero()) return feeResult } + NonBooleanPropertyPrefixedWithIs:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer$private val isAccountInitializedProvider: Provider<Boolean> + NonBooleanPropertyPrefixedWithIs:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$val isStakingEnabled = getStakingAvailabilityUseCase.invokeSync( userWalletId = selectedUserWalletId, cryptoCurrency = cryptoCurrency, ).getOrNull() + NonBooleanPropertyPrefixedWithIs:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader$private val isFeeApproximateUseCase: IsFeeApproximateUseCase + NonBooleanPropertyPrefixedWithIs:StakingModel.kt$StakingModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:StakingModel.kt$StakingModel$private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase + NonBooleanPropertyPrefixedWithIs:StakingModel.kt$StakingModel$private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase + NonBooleanPropertyPrefixedWithIs:StakingModel.kt$StakingModel$private val isBalanceHiddenFlow: StateFlow<Boolean> field = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:StakingTransactionSender.kt$StakingTransactionSender$private val isFeeApproximateUseCase: IsFeeApproximateUseCase + NullCheckOnMutableProperty:StakingModel.kt$StakingModel$if (feeCryptoCurrencyStatus != null && fee != null) { getBalanceNotEnoughForFeeWarningUseCase( fee = fee, userWalletId = userWalletId, tokenStatus = cryptoCurrencyStatus, coinStatus = feeCryptoCurrencyStatus ?: cryptoCurrencyStatus, ).getOrNull() } else { null } + NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$networkId + NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$tokenId + PropertyUsedBeforeDeclaration:StakingFeeBlock.kt$FeeBlockPreviewProvider$contentState + PropertyUsedBeforeDeclaration:StakingModel.kt$StakingModel$isAmountSubtractAvailable + PropertyUsedBeforeDeclaration:StakingStateController.kt$StakingStateController$uiState + ReusedModifierInstance:StakingClaimRewardsValidatorContent.kt$InputRowImageInfo( subtitle = item.title, caption = item.getAprTextNeutral(), infoTitle = item.formattedFiatAmount, infoSubtitle = item.formattedCryptoAmount, imageUrl = item.validator?.image.orEmpty(), onImageError = { ValidatorImagePlaceholder() }, modifier = modifier .roundedShapeItemDecoration(index, state.rewards.lastIndex, false) .background(TangemTheme.colors.background.action) .clickable( enabled = item.pendingActions.isNotEmpty(), onClick = { clickIntents.onActiveStake(item) }, ), ) + SuspendFunSwallowedCancellation:StakingModel.kt$StakingModel$runCatching + UnnecessaryEventHandlerParameter:StakingInitialInfoContent.kt$onClick: (BalanceState) -> Unit + UnnecessaryFilter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory$filter { it.validator?.address == validatorAddress } + UnnecessaryLet:StakingModel.kt$StakingModel$let(::add) + UnnecessaryLet:StakingTosText.kt$let { onTextClick(PRIVACY_POLICY_URL) } + UnnecessaryLet:StakingTosText.kt$let { onTextClick(TERMS_OF_USE_URL) } + UnsafeCallOnNullableType:StakingModel.kt$StakingModel$tonAccountInitializeTransaction!! + UseOrEmpty:StakingModel.kt$StakingModel$yieldBalance?.balance?.items ?: emptyList() + UseOrEmpty:StakingTransactionSender.kt$StakingTransactionSender$getExplorerTransactionUrlUseCase( txHash = transactionHashes.last(), networkId = cryptoCurrencyStatus.currency.network.id, ).getOrNull() ?: "" + VarCouldBeVal:StakingModel.kt$StakingModel$private var actionsJobHolder: JobHolder = JobHolder() + VarCouldBeVal:StakingModel.kt$StakingModel$private var approvalJobHolder: JobHolder = JobHolder() + VarCouldBeVal:StakingModel.kt$StakingModel$private var feeJobHolder: JobHolder = JobHolder() + VarCouldBeVal:StakingModel.kt$StakingModel$private var sendTransactionJobHolder = JobHolder() + VarCouldBeVal:StakingModel.kt$StakingModel$private var stakingStateRouter: StakingStateRouter = StakingStateRouter( appRouter = appRouter, stateController = stateController, analyticsEventsHandler = analyticsEventHandler, ) + VarCouldBeVal:StakingModel.kt$StakingModel$private var stepChangesJobHolder = JobHolder() + + 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 673c1398e0..eebecedbcf 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, @@ -1066,60 +1076,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() { @@ -1127,7 +1152,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) @@ -1202,6 +1234,9 @@ internal class StakingModel @Inject constructor( userWalletProvider = Provider { userWallet }, appCurrencyProvider = Provider { appCurrency }, balancesToShowProvider = Provider { balancesToShow }, + isAccountsModeEnabled = isAccountsModeEnabled, + account = account, + isBalanceHidden = isBalanceHiddenFlow.value, ), SetConfirmationStateEmptyTransformer, ) @@ -1236,6 +1271,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/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt index 54644cf01f..962d4cd6c5 100644 --- a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt @@ -6,7 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.WATCHED sealed class StoriesEvents( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent("Stories", event, params) { data class SwapStories( diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt index 1d3c2f06b6..700e81d255 100644 --- a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt @@ -27,12 +27,11 @@ internal class StoriesModel @Inject constructor( private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { - val state: StateFlow get() = _state + + val state: StateFlow + field = MutableStateFlow(value = SwapStoriesUM.Empty) private val params = paramsContainer.require() - private val _state: MutableStateFlow = MutableStateFlow( - value = SwapStoriesUM.Empty, - ) init { initStories() @@ -59,7 +58,7 @@ internal class StoriesModel @Inject constructor( if (swapStory == null) { openScreen(hideStories = false) // Fallback to target screen } else { - _state.update { + state.update { SwapStoriesFactory.createStoriesState( swapStory = swapStory, onStoriesClose = { watchCount -> diff --git a/features/swap-v2/impl/detekt-baseline-debug.xml b/features/swap-v2/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..3bc57815b3 --- /dev/null +++ b/features/swap-v2/impl/detekt-baseline-debug.xml @@ -0,0 +1,59 @@ + + + + + BooleanPropertyNaming:ConfirmUM.kt$ConfirmUM.Content$val showTapHelp: Boolean + BooleanPropertyNaming:SwapAmountModel.kt$SwapAmountModel$private var showBestRateAnimation: Boolean = false + BooleanPropertyNaming:SwapAmountModel.kt$SwapAmountModel$val showSendViaSwapNotification = shouldShowNotificationUseCase( NotificationId.SendViaSwapTokenSelectorNotification.key, ) + BooleanPropertyNaming:SwapAmountPrimaryReadyStateTransformer.kt$SwapAmountPrimaryReadyStateTransformer$private val showBestRateAnimation: Boolean + BooleanPropertyNaming:SwapAmountSecondaryReadyStateTransformer.kt$SwapAmountSecondaryReadyStateTransformer$private val showBestRateAnimation: Boolean + BooleanPropertyNaming:SwapAmountSelectQuoteTransformer.kt$SwapAmountSelectQuoteTransformer$private val needApplyFCARestrictions: Boolean + BooleanPropertyNaming:SwapAmountSetQuotesTransformer.kt$SwapAmountSetQuotesTransformer$private val needApplyFcaRestrictions: Boolean + BooleanPropertyNaming:SwapAmountUM.kt$SwapAmountUM.Content$val showBestRateAnimation: Boolean + BooleanPropertyNaming:SwapAmountUM.kt$SwapAmountUM.Content$val showFCAWarning: Boolean + BooleanPropertyNaming:SwapChooseProviderModel.kt$SwapChooseProviderModel$private val needApplyFCARestrictions = params.userCountry.needApplyFCARestrictions() + BooleanPropertyNaming:SwapProviderListItemConverter.kt$SwapProviderListItemConverter$private val needApplyFCARestrictions: Boolean + BooleanPropertyNaming:SwapProviderStateConverter.kt$SwapProviderStateConverter$private val needApplyFCARestrictions: Boolean + CouldBeSequence:SwapAmountModel.kt$SwapAmountModel$filter { it.currencyStatus.currency.id == toCryptoCurrency.id } + MaxChainedCallsOnSameLine:SwapAlertFactory.kt$SwapAlertFactory$confirmData?.fee?.amount?.value?.toString().orEmpty() + MultilineLambdaItParameter:SendWithSwapConfirmModel.kt$SendWithSwapConfirmModel${ it.copy( confirmUM = SendWithSwapConfirmInitialStateTransformer( isShowTapHelp = isShowTapHelp, ).transform(uiState.value.confirmUM), ) } + MultilineLambdaItParameter:SendWithSwapConfirmModel.kt$SendWithSwapConfirmModel${ val confirmUM = it.confirmUM as? ConfirmUM.Content it.copy(confirmUM = confirmUM?.copy(showTapHelp = showTapHelp) ?: it.confirmUM) } + MultilineLambdaItParameter:SendWithSwapConfirmModel.kt$SendWithSwapConfirmModel${ val feeUM = it.feeSelectorUM as? FeeSelectorUM.Content it.copy( confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy( isPrimaryButtonEnabled = !hasError && feeUM != null, ) ?: it.confirmUM, ) } + MultilineLambdaItParameter:SendWithSwapConfirmationNotificationsTransformer.kt$SendWithSwapConfirmationNotificationsTransformer${ ConfirmUM.Content.LegalUM( title = resourceReference(R.string.common_privacy_policy), link = it, ) } + MultilineLambdaItParameter:SendWithSwapConfirmationNotificationsTransformer.kt$SendWithSwapConfirmationNotificationsTransformer${ ConfirmUM.Content.LegalUM( title = resourceReference(R.string.common_terms_of_use), link = it, ) } + MultilineLambdaItParameter:SendWithSwapModel.kt$SendWithSwapModel${ Timber.w(it.toString()) swapAlertFactory.getGenericErrorState( expressError = ExpressError.UnknownError, onFailedTxEmailClick = { modelScope.launch { swapAlertFactory.onFailedTxEmailClick( userWallet = userWallet, cryptoCurrency = params.currency, errorMessage = it.toString(), ) } }, popBack = ::onBackClick, ) } + MultilineLambdaItParameter:SendWithSwapModel.kt$SendWithSwapModel${ it.copy( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, navigationUM = NavigationUM.Empty, ) } + MultilineLambdaItParameter:SwapAmountSetQuotesTransformer.kt$SwapAmountSetQuotesTransformer${ it is SwapQuoteUM.Content || it is SwapQuoteUM.Allowance || (it as? SwapQuoteUM.Error)?.expressError is ExpressError.AmountError } + MultilineLambdaItParameter:SwapChooseProviderModel.kt$SwapChooseProviderModel${ it is SwapQuoteUM.Content || it is SwapQuoteUM.Allowance || (it as? SwapQuoteUM.Error)?.expressError is ExpressError.AmountError } + MultilineLambdaItParameter:SwapChooseTokenNetworkModel.kt$SwapChooseTokenNetworkModel${ Timber.e("Failed to get user wallet: $it") swapChooseTokenAlertFactory.getGenericErrorState(params.onDismiss) return } + MultilineLambdaItParameter:SwapChooseTokenNetworkModel.kt$SwapChooseTokenNetworkModel${ Timber.e(it.toString()) uiState.update( SwapChooseErrorStateTransformer( tokenName = params.token.name, onDismiss = params.onDismiss, ), ) return@launch } + MultilineLambdaItParameter:SwapTransactionSender.kt$SwapTransactionSender${ Timber.e(it, "Failed to create swap CEX tx data") onSendError(SendTransactionError.UnknownError(Exception(it))) return } + MultilineLambdaItParameter:SwapTransactionSender.kt$SwapTransactionSender${ onExpressError(it); return } + NamedArguments:SwapTransactionSender.kt$SwapTransactionSender$getSwapDataUseCase( userWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, fromAmount = fromAmount.toStringWithRightOffset(fromStatus.currency.decimals), toCryptoCurrency = toStatus.currency, toAddress = destination, expressProvider = provider, rateType = rateType, expressOperationType, ) + NoNameShadowing:SendWithSwapContent.kt$navigationUM + NoNameShadowing:SwapAmountContent.kt$amountFieldUM + NonBooleanPropertyPrefixedWithIs:SendWithSwapConfirmComponent.kt$SendWithSwapConfirmComponent.Params$val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendWithSwapConfirmComponent.kt$SendWithSwapConfirmComponent.Params$val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SendWithSwapConfirmModel.kt$SendWithSwapConfirmModel$private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase + NonBooleanPropertyPrefixedWithIs:SendWithSwapConfirmModel.kt$SendWithSwapConfirmModel$private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase + NonBooleanPropertyPrefixedWithIs:SendWithSwapModel.kt$SendWithSwapModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:SendWithSwapModel.kt$SendWithSwapModel$val isAccountModeFlow: StateFlow<Boolean> field = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:SendWithSwapModel.kt$SendWithSwapModel$val isBalanceHiddenFlow: StateFlow<Boolean> field = MutableStateFlow(false) + NonBooleanPropertyPrefixedWithIs:SwapAmountComponentParams.kt$SwapAmountComponentParams$abstract val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SwapAmountComponentParams.kt$SwapAmountComponentParams$abstract val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SwapAmountComponentParams.kt$SwapAmountComponentParams.AmountBlockParams$override val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SwapAmountComponentParams.kt$SwapAmountComponentParams.AmountBlockParams$override val isBalanceHidingFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SwapAmountComponentParams.kt$SwapAmountComponentParams.AmountParams$override val isAccountModeFlow: StateFlow<Boolean> + NonBooleanPropertyPrefixedWithIs:SwapAmountComponentParams.kt$SwapAmountComponentParams.AmountParams$override val isBalanceHidingFlow: StateFlow<Boolean> + NullableToStringCall:SendWithSwapConfirmModel.kt$SendWithSwapConfirmModel$error.toString() + NullableToStringCall:SwapAmountModel.kt$SwapAmountModel$$primaryStatus + NullableToStringCall:SwapAmountModel.kt$SwapAmountModel$$secondaryStatus + PropertyUsedBeforeDeclaration:SendWithSwapConfirmModel.kt$SendWithSwapConfirmModel$amountUM + UnnecessaryEventHandlerParameter:SwapAmountContent.kt$onExpandEditField: (SwapAmountType) -> Unit + UseEmptyCounterpart:SendWithSwapAnalyticEvents.kt$SendWithSwapAnalyticEvents$mapOf() + UseEmptyCounterpart:SendWithSwapSuccessContent.kt$listOf() + UseEmptyCounterpart:SwapAmountAnalyticEvents.kt$SwapAmountAnalyticEvents$mapOf() + VarCouldBeVal:SwapAmountModel.kt$SwapAmountModel$private var primaryCryptoCurrency: CryptoCurrency = params.primaryCryptoCurrencyStatusFlow.value.currency + VarCouldBeVal:SwapAmountModel.kt$SwapAmountModel$private var userWallet = params.userWallet + + diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 6d26842357..70fe7d5312 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -600,7 +600,7 @@ internal class SwapAmountModel @Inject constructor( return } - if (!isSilentReload) { uiState.transformerUpdate(SwapQuoteLoadingStateTransformer) } + if (!isSilentReload) uiState.transformerUpdate(SwapQuoteLoadingStateTransformer) modelScope.launch { val quotes = state.swapCurrencies.getGroupWithDirection(state.swapDirection).available.filter { 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 58c4d854b7..8d04b3d804 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/detekt-baseline-debug.xml b/features/swap/data/detekt-baseline-debug.xml new file mode 100644 index 0000000000..640fd2fbc1 --- /dev/null +++ b/features/swap/data/detekt-baseline-debug.xml @@ -0,0 +1,21 @@ + + + + + BooleanPropertyNaming:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository$val fromCurrency = it.fromCryptoCurrencyId == cryptoCurrencyId.value + BooleanPropertyNaming:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository$val toCurrency = it.toCryptoCurrencyId == cryptoCurrencyId.value + MultilineLambdaItParameter:DefaultSwapRepository.kt$DefaultSwapRepository${ Timber.e("getExchangeStatus error: $it") raise(UnknownError(it.message)) } + MultilineLambdaItParameter:DefaultSwapRepository.kt$DefaultSwapRepository${ val requirements = walletManagersFacade.getAssetRequirements(userWallet.walletId, it) val isAvailableForSwap = rampStateManager.checkAssetRequirements(requirements) isAvailableForSwap } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ converter.convertBack( value = it, userWallet = userWallet, accountList = accountList, txStatuses = txStatuses, ) } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ converter.convertBack( value = it, userWallet = userWallet, accountList = accountList, txStatuses = txStatuses, onFilter = { it.swapTxTypeDTO == SwapTxTypeDTO.Swap }, ) } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.checkId( checkUserWalletId = userWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ storeTransactionState( txId = transaction.txId, status = it, accountWithCurrency = null, ) } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ val isUserWallet = it.userWalletId == userWallet.walletId.stringValue val fromCurrency = it.fromCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && fromCurrency } + MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ val isUserWallet = it.userWalletId == userWallet.walletId.stringValue val toCurrency = it.toCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && toCurrency } + MultilineLambdaItParameter:ExpressDataConverter.kt$ExpressDataConverter${ if (it == "0") { BigDecimal.ZERO } else { requireNotNull(it.toBigDecimalOrNull()) { "wrong amount format, use only digits" } } } + NoNameShadowing:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.swapTxTypeDTO == SwapTxTypeDTO.Swap } + NoNameShadowing:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.txId == txId } + UseOrEmpty:DefaultSwapRepository.kt$DefaultSwapRepository$ex.errorBody ?: "" + UseOrEmpty:DefaultSwapRepository.kt$DefaultSwapRepository$exception.errorBody ?: "" + + 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 f890932dfe..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(toToken, 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/detekt-baseline-debug.xml b/features/swap/domain/detekt-baseline-debug.xml new file mode 100644 index 0000000000..02326b7307 --- /dev/null +++ b/features/swap/domain/detekt-baseline-debug.xml @@ -0,0 +1,33 @@ + + + + + BooleanPropertyNaming:SwapInteractorImpl.kt$SwapInteractorImpl$val allQuotesFound = cachedQuotes?.all { it.value !is QuoteStatus.Empty } == true + BooleanPropertyNaming:SwapInteractorImpl.kt$SwapInteractorImpl$val currencyFilter = it.currency.network.backendId != currency.network.backendId || it.currency.getContractAddress() != currency.getContractAddress() + BooleanPropertyNaming:SwapInteractorImpl.kt$SwapInteractorImpl$val notCustomTokenFilter = !it.currency.isCustom + BooleanPropertyNaming:SwapInteractorImpl.kt$SwapInteractorImpl$val statusFilter = it.value is CryptoCurrencyStatus.Loaded || it.value is CryptoCurrencyStatus.NoAccount + CanBeNonNullable:SwapInteractorImpl.kt$SwapInteractorImpl$spenderAddress: String? + MaxChainedCallsOnSameLine:SwapInteractorImpl.kt$SwapInteractorImpl$currencyToGet.value.networkAddress?.defaultAddress?.value.orEmpty() + MaxChainedCallsOnSameLine:SwapInteractorImpl.kt$SwapInteractorImpl$currencyToSend.value.networkAddress?.defaultAddress?.value.orEmpty() + MaxChainedCallsOnSameLine:SwapInteractorImpl.kt$SwapInteractorImpl$currencyToSendStatus.value.networkAddress?.defaultAddress?.value.orEmpty() + MaxChainedCallsOnSameLine:SwapInteractorImpl.kt$SwapInteractorImpl$fromToken.value.networkAddress?.defaultAddress?.value.orEmpty() + MaxChainedCallsOnSameLine:SwapInteractorImpl.kt$SwapInteractorImpl$toToken.value.networkAddress?.defaultAddress?.value.orEmpty() + MultilineLambdaItParameter:SwapInteractorImpl.kt$SwapInteractorImpl${ Timber.e(it, "Failed to create approveTransaction") return SwapTransactionState.Error.UnknownError } + MultilineLambdaItParameter:SwapInteractorImpl.kt$SwapInteractorImpl${ Timber.e(it, "Failed to create swap CEX tx data") return SwapTransactionState.Error.UnknownError } + MultilineLambdaItParameter:SwapInteractorImpl.kt$SwapInteractorImpl${ Timber.e(it, "Failed to create swap dex tx data") return SwapTransactionState.Error.UnknownError } + MultilineLambdaItParameter:SwapInteractorImpl.kt$SwapInteractorImpl${ it.contractAddress.equals(feePaidCurrency.contractAddress, ignoreCase = true) && it.network.derivationPath == fromTokenStatus.currency.network.derivationPath } + MultilineLambdaItParameter:SwapInteractorImpl.kt$SwapInteractorImpl${ tokenInfoForFilter(it).contractAddress == currency.getContractAddress() && tokenInfoForFilter(it).network == currency.network.backendId } + MultilineLambdaItParameter:SwapInteractorImpl.kt$SwapInteractorImpl${ 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 } + MultilineLambdaItParameter:SwapInteractorImpl.kt$SwapInteractorImpl${ val listTokenInfo = tokenInfoForAvailable(it) if (cryptoCurrencyStatuses.currency.network.backendId == listTokenInfo.network && cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress && isAvailableForSwap ) { it.providers } else { null } } + NamedArguments:SwapInteractorImpl.kt$SwapInteractorImpl$getCoinBalanceAfterTransaction(fromTokenStatus, amount, includeFeeInAmount, fee) + NamedArguments:SwapInteractorImpl.kt$SwapInteractorImpl$isAllowedToSpend(networkId, fromToken.currency, amount, it) + NamedArguments:SwapInteractorImpl.kt$SwapInteractorImpl$tryGetFromCache(userWallet, initialCryptoCurrency, state, isReverseFromTo) + NamedArguments:SwapInteractorImpl.kt$SwapInteractorImpl$tryGetFromCacheV2(userWallet, initialCryptoCurrency, state, isReverseFromTo) + NoNameShadowing:SwapInteractorImpl.kt$SwapInteractorImpl$account + NoNameShadowing:SwapInteractorImpl.kt$SwapInteractorImpl${ it.isAvailable } + NonBooleanPropertyPrefixedWithIs:SwapInteractorImpl.kt$SwapInteractorImpl$private val isDemoCardUseCase: IsDemoCardUseCase + NullableToStringCall:SwapInteractorImpl.kt$SwapInteractorImpl$$swapData + NullableToStringCall:SwapInteractorImpl.kt$SwapInteractorImpl$${e.message} + SuspendFunSwallowedCancellation:SwapInteractorImpl.kt$SwapInteractorImpl$runCatching + + diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt index 69300406c2..d066e86fe7 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt @@ -32,7 +32,7 @@ data class ExchangeStatusModel( val averageDuration: Int? = null, ) { val hasLongTime: Boolean - get() = createdAt?.isBefore(DateTime.now().minusMinutes(FIFTEEN_MINUTES)) ?: false + get() = createdAt?.isBefore(DateTime.now().minusMinutes(FIFTEEN_MINUTES)) == true private companion object { const val FIFTEEN_MINUTES = 15 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..f0587337d6 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,21 @@ 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( + available = emptyList(), + unavailable = emptyList(), + accountCurrencyList = emptyList(), + isAfterSearch = false, + ), + toGroup = CurrenciesGroup( + available = emptyList(), + unavailable = emptyList(), + accountCurrencyList = emptyList(), + isAfterSearch = false, + ), + allProviders = emptyList(), + ) } } @@ -29,5 +40,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/detekt-baseline-debug.xml b/features/swap/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..e7ed3b9972 --- /dev/null +++ b/features/swap/impl/detekt-baseline-debug.xml @@ -0,0 +1,79 @@ + + + + + BooleanPropertyNaming:ChooseFeeBottomSheet.kt$val showDivider = content.feeItems.lastIndex != index + BooleanPropertyNaming:SwapEvents.kt$SwapEvents.ChooseTokenScreenOpened$val availableTokens: Boolean + BooleanPropertyNaming:SwapEvents.kt$SwapEvents.ChooseTokenScreenResult$val tokenChosen: Boolean + BooleanPropertyNaming:SwapNotificationsFactory.kt$SwapNotificationsFactory$val needShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeEnoughState.feeCurrency != fromToken + BooleanPropertyNaming:SwapSelectTokenStateHolder.kt$SwapSelectTokenStateHolder$val afterSearch: Boolean + BooleanPropertyNaming:SwapSelectTokenStateHolder.kt$TokenToSelectState.TokenToSelect$val available: Boolean = true + BooleanPropertyNaming:SwapStateHolder.kt$SwapButton$val enabled: Boolean + BooleanPropertyNaming:SwapStateHolder.kt$TransactionCardType.ReadOnly$val showWarning: Boolean = false + BooleanPropertyNaming:SwapSuccessStateHolder.kt$SwapSuccessStateHolder$val showStatusButton: Boolean + CastNullableToNonNullableType:SwapModel.kt$SwapModel$as + MaxChainedCallsOnSameLine:SwapModel.kt$SwapModel$it.value.toTokenInfo.cryptoCurrencyStatus.currency.decimals + MaxChainedCallsOnSameLine:SwapNotificationsFactory.kt$SwapNotificationsFactory$quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency.network.currencySymbol + MultilineLambdaItParameter:AccountTokenItemConverter.kt$AccountTokenItemConverter${ TokenItemState.TitleState.Content( text = stringReference(value = it.currency.name), isAvailable = false, ) } + MultilineLambdaItParameter:AccountTokenItemConverter.kt$AccountTokenItemConverter${ createSubtitleState( status = it, isAvailable = false, text = unavailableErrorText, ) } + MultilineLambdaItParameter:AccountTokenItemConverter.kt$AccountTokenItemConverter${ createSubtitleState( status = it, isAvailable = true, text = stringReference(value = it.currency.symbol), ) } + MultilineLambdaItParameter:ProviderItem.kt${ Text( text = if (it > 0) "+$it%" else "$it%", style = TangemTheme.typography.body2, color = textColor, modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), overflow = TextOverflow.Ellipsis, maxLines = 1, ) } + MultilineLambdaItParameter:ProviderItem.kt${ Text( text = it, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.primary1, ) } + MultilineLambdaItParameter:ProviderItem.kt${ Text( text = it, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) } + MultilineLambdaItParameter:ProviderItem.kt${ Text( text = it, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), ) } + MultilineLambdaItParameter:ProviderItem.kt${ Text( text = it.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.padding(top = TangemTheme.dimens.spacing6), ) } + MultilineLambdaItParameter:ProviderItem.kt${ Text( text = it.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } + MultilineLambdaItParameter:StateBuilder.kt$StateBuilder${ LegalState( title = resourceReference(R.string.common_privacy_policy), link = it, onClick = actions.onLinkClick, ) } + MultilineLambdaItParameter:StateBuilder.kt$StateBuilder${ LegalState( title = resourceReference(R.string.common_terms_of_use), link = it, onClick = actions.onLinkClick, ) } + MultilineLambdaItParameter:StateBuilder.kt$StateBuilder${ it is SwapNotificationUM.Error || it is NotificationUM.Error || it is SwapNotificationUM.Warning.ExpressError || it is SwapNotificationUM.Warning.ExpressGeneralError || it is SwapNotificationUM.Warning.NoAvailableTokensToSwap || it is SwapNotificationUM.Warning.NeedReserveToCreateAccount || it is SwapNotificationUM.Info.PermissionNeeded } + MultilineLambdaItParameter:StateBuilder.kt$StateBuilder${ it.convertToProviderBottomSheetState( pricesLowerBest = pricesLowerBest, onProviderSelect = actions.onProviderSelect, needApplyFCARestrictions = needApplyFCARestrictions, ) } + MultilineLambdaItParameter:StateBuilder.kt$StateBuilder${ val selectedItem = when (it) { FeeType.NORMAL -> txFeeState.normalFee FeeType.PRIORITY -> txFeeState.priorityFee } actions.onSelectFeeType.invoke(selectedItem) } + MultilineLambdaItParameter:StateBuilder.kt$StateBuilder${ val tokenInfo = tokenSwapInfoForProviders[it.id] if (it is ProviderState.Content && tokenInfo != null) { val rateString = tokenInfo.tokenAmount .getFormattedCryptoAmount(tokenInfo.cryptoCurrencyStatus.currency) it.copy( subtitle = stringReference(rateString), percentLowerThenBest = pricesLowerBest[it.id]?.let { percent -> PercentDifference.Value(percent) } ?: PercentDifference.Value(0f), ) } else { it } } + MultilineLambdaItParameter:StateBuilder.kt$StateBuilder${ when (it) { is TokenToSelectState.TokenToSelect -> { it.copy( addedTokenBalanceData = it.addedTokenBalanceData?.copy(isBalanceHidden = isBalanceHidden), ) } is TokenToSelectState.Title -> { it } } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ AccountCryptoCurrencyStatus( account = it.account, status = it.cryptoCurrencyStatus, ) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ Timber.d("${coin.id} balance is ${it.value.amount}") 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) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ Timber.e("Error when loading quotes: $it") uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ Timber.e(it) applyInitialTokenChoice( state = TokensDataStateExpress.EMPTY, selectedCurrency = null, selectedAccount = null, isReverseFromTo = isReverseFromTo, ) uiState = stateBuilder.createInitialErrorState( uiState, (it as? ExpressException)?.expressDataError?.code ?: ExpressDataError.UnknownError.code, ) { uiState = stateBuilder.createInitialLoadingState( initialCurrencyFrom = initialCurrencyFrom, initialCurrencyTo = initialCurrencyTo, fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), ) initTokens(isReverseFromTo) } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ Timber.e(it) startLoadingQuotesFromLastState() makeDefaultAlert() } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ Timber.e(it.message.orEmpty()) makeDefaultAlert() } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ getAccountCurrencyStatusUseCase.invokeSync( userWalletId = userWalletId, currency = it, ).getOrNull() } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( userWalletId = userWalletId, cryptoCurrencyId = it.id, ).getOrNull() } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ if (!it.value.fromTokenInfo.amountFiat.isNullOrZero() && !it.value.toTokenInfo.amountFiat.isNullOrZero()) { it.value.fromTokenInfo.amountFiat.divide( it.value.toTokenInfo.amountFiat, it.value.toTokenInfo.cryptoCurrencyStatus.currency.decimals, RoundingMode.HALF_UP, ) } else { BigDecimal.ZERO } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ if (it.key != selectedProviderEntry.key) { val amount = it.value.toTokenInfo.tokenAmount.value val percentDiff = BigDecimal.ONE.minus( selectedProviderRate.divide(amount, RoundingMode.HALF_UP), ).multiply(hundredPercent) it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat() } else { null } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ isBalanceHidden = it.isBalanceHidden uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ it is SwapState.SwapError && ( it.error is ExpressDataError.ExchangeTooSmallAmountError || it.error is ExpressDataError.ExchangeTooBigAmountError ) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ subscribeToCoinBalanceUpdates( userWalletId = userWalletId, coin = it, isFromCurrency = false, ) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ subscribeToCoinBalanceUpdates( userWalletId = userWalletId, coin = it, isFromCurrency = true, ) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ uiState = stateBuilder.dismissBottomSheet(uiState) dataState = dataState.copy(selectedFee = it) modelScope.launch(dispatchers.io) { startLoadingQuotesFromLastState(false) } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ val balance = swapInteractor.getTokenBalance(it) onAmountChanged(balance.formatToUIRepresentation()) } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ val provider = findAndSelectProvider(it) val swapState = dataState.lastLoadedSwapStates[provider] val fromToken = dataState.fromCryptoCurrency if (provider != null && swapState != null && fromToken != null) { analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) uiState = stateBuilder.dismissBottomSheet(uiState) setupLoadedState( provider = provider, state = swapState, fromToken = fromToken, ) } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ when (it) { is SwapTransactionState.TxSent -> { sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType) updateWalletBalance() uiState = stateBuilder.loadingPermissionState(uiState) uiState = stateBuilder.dismissBottomSheet(uiState) startLoadingQuotesFromLastState(isSilent = true) } is SwapTransactionState.Error -> { uiState = stateBuilder.createErrorTransactionAlert( uiState = uiState, error = it, onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, onSupportClick = ::onFailedTxEmailClick, isReverseSwapPossible = isReverseSwapPossible(), ) } SwapTransactionState.DemoMode -> { uiState = stateBuilder.createDemoModeAlert( uiState = uiState, onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, isReverseSwapPossible = isReverseSwapPossible(), ) } } } + MultilineLambdaItParameter:SwapModel.kt$SwapModel${ when (it) { is SwapTransactionState.TxSent -> { sendSuccessSwapEvent(fromCurrency.currency, fee.feeType) val url = getExplorerTransactionUrlUseCase( txHash = it.txHash, networkId = fromCurrency.currency.network.id, ).getOrElse { Timber.i("tx hash explore not supported") "" } updateWalletBalance() uiState = stateBuilder.createSuccessState( uiState = uiState, swapTransactionState = it, dataState = dataState, txUrl = url, onExploreClick = { if (it.txHash.isNotEmpty()) { urlOpener.openUrl(url) } analyticsEventHandler.send( event = SwapEvents.ButtonExplore(initialCurrencyFrom.symbol), ) }, onStatusClick = { val txExternalUrl = it.txExternalUrl if (!txExternalUrl.isNullOrBlank()) { urlOpener.openUrl(txExternalUrl) analyticsEventHandler.send( event = SwapEvents.ButtonStatus(initialCurrencyFrom.symbol), ) } }, ) sendSuccessEvent() swapRouter.openScreen(SwapNavScreen.Success) } SwapTransactionState.DemoMode -> { uiState = stateBuilder.createDemoModeAlert( uiState = uiState, onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, isReverseSwapPossible = isReverseSwapPossible(), ) } is SwapTransactionState.Error -> { startLoadingQuotesFromLastState() uiState = stateBuilder.createErrorTransactionAlert( uiState = uiState, error = it, onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, onSupportClick = ::onFailedTxEmailClick, isReverseSwapPossible = isReverseSwapPossible(), ) } } } + MultilineLambdaItParameter:TransactionCard.kt${ Text( text = it, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, ) } + MultilineLambdaItParameter:TransactionCard.kt${ Text( text = it, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, modifier = Modifier .align(Alignment.CenterVertically) .testTag(SwapTokenScreenTestTags.BALANCE), ) } + MultilineLambdaItParameter:TransactionCard.kt${ Text( text = it, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20), ) } + NamedArguments:SwapModel.kt$SwapModel$PeriodicTask( UPDATE_DELAY, task = { uiState = stateBuilder.createSilentLoadState(uiState) runCatching(dispatchers.io) { dataState = dataState.copy( amount = amount, reduceBalanceBy = reduceBalanceBy, swapDataModel = null, approveDataModel = null, ) swapInteractor.findBestQuote( fromToken = fromToken, fromAccount = fromAccount, toToken = toToken, toAccount = toAccount, providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, ) } }, onSuccess = { providersState -> if (providersState.isNotEmpty()) { val (provider, state) = updateLoadedQuotes(providersState) setupLoadedState(provider, state, fromToken) val successStates = providersState.getLastLoadedSuccessStates() val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) uiState = stateBuilder.updateProvidersBottomSheetContent( uiState = uiState, pricesLowerBest = pricesLowerBest, tokenSwapInfoForProviders = successStates.entries .associate { it.key.providerId to it.value.toTokenInfo }, ) } else { Timber.e("Accidentally empty quotes list") } }, onError = { Timber.e("Error when loading quotes: $it") uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } }, ) + NoNameShadowing:SwapModel.kt$SwapModel${ it.cryptoCurrencyStatus } + NoNameShadowing:SwapModel.kt$SwapModel${ it.cryptoCurrencyStatus.currency.id.value == id } + NoNameShadowing:SwapModel.kt$SwapModel${ it.key == selectedSwapProvider } + NonBooleanPropertyPrefixedWithIs:StateBuilder.kt$StateBuilder$private val isAccountsModeProvider: Provider<Boolean> + NonBooleanPropertyPrefixedWithIs:StateBuilder.kt$StateBuilder$private val isBalanceHiddenProvider: Provider<Boolean> + NonBooleanPropertyPrefixedWithIs:SwapModel.kt$SwapModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase + NonBooleanPropertyPrefixedWithIs:TokensDataConverter.kt$TokensDataConverter$private val isBalanceHiddenProvider: Provider<Boolean> + NullableToStringCall:SwapModel.kt$SwapModel$${currencyStatus.value.amount} + NullableToStringCall:SwapModel.kt$SwapModel$${it.value.amount} + NullableToStringCall:TransactionCard.kt$data.toString() + PropertyUsedBeforeDeclaration:SwapModel.kt$SwapModel$isAccountsMode + SuspendFunSwallowedCancellation:SwapModel.kt$SwapModel$runCatching + UnnecessaryEventHandlerParameter:ChooseFeeBottomSheet.kt$onReadMoreClick: (String) -> Unit + UnnecessaryLet:SwapModel.kt$SwapModel$let { return nonEmptyStates.entries.first { it.key == selectedSwapProvider }.toPair() } + UnusedImports:TransactionCard.kt$import com.tangem.domain.models.account.Account + UseEmptyCounterpart:StoriesEvents.kt$StoriesEvents$mapOf() + UseEmptyCounterpart:SwapEvents.kt$SwapEvents$mapOf() + UseOrEmpty:StateBuilder.kt$StateBuilder$initialCurrencyTo?.symbol ?: "" + UseOrEmpty:SwapModel.kt$SwapModel$receiveToken ?: "" + UseOrEmpty:SwapScreenContent.kt$swapCardState.tokenIconUrl ?: "" + VarCouldBeVal:SwapModel.kt$SwapModel$private var swapRouter: SwapRouter = SwapRouter(router = router) + + 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..7619ebe375 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,45 @@ 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 = getFromStatus() + 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 +267,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 +280,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 +330,7 @@ internal class SwapModel @Inject constructor( applyInitialTokenChoice( state = TokensDataStateExpress.EMPTY, selectedCurrency = null, + selectedAccount = null, isReverseFromTo = isReverseFromTo, ) @@ -304,6 +367,7 @@ internal class SwapModel @Inject constructor( private fun applyInitialTokenChoice( state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?, + selectedAccount: Account.CryptoPortfolio?, isReverseFromTo: Boolean, ) { // exceptional case @@ -321,14 +385,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 +414,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 +443,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 +472,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 +485,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 +505,9 @@ internal class SwapModel @Inject constructor( ) swapInteractor.findBestQuote( fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, @@ -475,6 +573,7 @@ internal class SwapModel @Inject constructor( fromTokenStatus = fromToken, toTokenStatus = toTokenStatus, isReverseSwapPossible = isReverseSwapPossible(), + toAccount = dataState.toAccount, ) } is SwapState.SwapError -> { @@ -488,6 +587,7 @@ internal class SwapModel @Inject constructor( includeFeeInAmount = state.includeFeeInAmount, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + toAccount = dataState.toAccount, ) sendErrorAnalyticsEvent(state.error, provider) } @@ -614,6 +714,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, @@ -659,9 +761,11 @@ internal class SwapModel @Inject constructor( swapRouter.openScreen(SwapNavScreen.Success) } SwapTransactionState.DemoMode -> { - uiState = stateBuilder.createDemoModeAlert(uiState) { - uiState = stateBuilder.clearAlert(uiState) - } + uiState = stateBuilder.createDemoModeAlert( + uiState = uiState, + onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, + isReverseSwapPossible = isReverseSwapPossible(), + ) } is SwapTransactionState.Error -> { startLoadingQuotesFromLastState() @@ -670,6 +774,7 @@ internal class SwapModel @Inject constructor( error = it, onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, onSupportClick = ::onFailedTxEmailClick, + isReverseSwapPossible = isReverseSwapPossible(), ) } } @@ -750,12 +855,15 @@ internal class SwapModel @Inject constructor( error = it, onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, onSupportClick = ::onFailedTxEmailClick, + isReverseSwapPossible = isReverseSwapPossible(), ) } SwapTransactionState.DemoMode -> { - uiState = stateBuilder.createDemoModeAlert(uiState) { - uiState = stateBuilder.clearAlert(uiState) - } + uiState = stateBuilder.createDemoModeAlert( + uiState = uiState, + onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, + isReverseSwapPossible = isReverseSwapPossible(), + ) } } }.onFailure { makeDefaultAlert() } @@ -774,6 +882,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 +891,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 +921,7 @@ internal class SwapModel @Inject constructor( toGroup = tokenDataState.toGroup.copy( available = available, unavailable = unavailable, + accountCurrencyList = accountCurrencyList, isAfterSearch = true, ), ) @@ -805,25 +932,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 +963,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 +987,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 +1006,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 +1035,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 +1101,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 +1132,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 +1170,7 @@ internal class SwapModel @Inject constructor( amountRaw = lastAmount.value, fromToken = fromToken.currency, minTxAmount = minTxAmount, + fromAccount = dataState.fromAccount, ) if (toToken != null) { @@ -982,7 +1181,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), @@ -1144,9 +1345,9 @@ internal class SwapModel @Inject constructor( isPriceImpact = isPriceImpact, token = currencySymbol, provider = selectedProvider, - ) { - uiState = stateBuilder.clearAlert(uiState) - } + isReverseSwapPossible = isReverseSwapPossible(), + onDismiss = { uiState = stateBuilder.clearAlert(uiState) }, + ) }, onLinkClick = urlOpener::openUrl, onSelectTokenClick = { @@ -1267,7 +1468,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 { @@ -1282,6 +1490,7 @@ internal class SwapModel @Inject constructor( } private fun isReverseSwapPossible(): Boolean { + if (tangemPayInput != null) return false val from = dataState.fromCryptoCurrency ?: return false val to = dataState.toCryptoCurrency ?: return false @@ -1293,9 +1502,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() { @@ -1392,6 +1603,22 @@ internal class SwapModel @Inject constructor( ) } + private suspend fun getFromStatus(): CryptoCurrencyStatus? { + return if (tangemPayInput != null) { + getTangemPayCurrencyStatusUseCase( + currency = initialCurrencyFrom, + cryptoAmount = tangemPayInput.cryptoAmount, + fiatAmount = tangemPayInput.fiatAmount, + depositAddress = tangemPayInput.depositAddress, + ) + } else { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId = userWalletId, + cryptoCurrencyId = initialCurrencyFrom.id, + ).getOrNull() + } + } + private companion object { const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L 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..43985f8975 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 @@ -25,7 +25,7 @@ internal data class SwapStateHolder( val notifications: ImmutableList = persistentListOf(), val isInsufficientFunds: Boolean, val event: StateEvent = consumedEvent(), - val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED, + val changeCardsButtonState: ChangeCardsButtonState, val providerState: ProviderState, val fee: FeeItemState = FeeItemState.Empty, @@ -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..53ea29f518 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, @@ -280,10 +291,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 +312,7 @@ internal class StateBuilder( } sendCardType.copy( inputError = error, - header = insufficientFundsHeader, + accountTitleUM = fromAccountTitleUM, ) } } @@ -321,7 +334,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,11 +369,7 @@ internal class StateBuilder( enabled = getSwapButtonEnabled(notifications), onClick = actions.onSwapClick, ), - changeCardsButtonState = if (isReverseSwapPossible) { - ChangeCardsButtonState.ENABLED - } else { - ChangeCardsButtonState.DISABLED - }, + changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), providerState = swapProvider.convertToContentClickableProviderState( isBestRate = bestRatedProviderId == swapProvider.providerId, fromTokenInfo = quoteModel.fromTokenInfo, @@ -423,6 +433,7 @@ internal class StateBuilder( swapProvider: SwapProvider, fromToken: TokenSwapInfo, toToken: CryptoCurrencyStatus?, + toAccount: Account.CryptoPortfolio?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, isReverseSwapPossible: Boolean, @@ -445,9 +456,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 +475,7 @@ internal class StateBuilder( isBalanceHidden = isBalanceHiddenProvider(), ) } ?: SwapCardState.Empty( - type = TransactionCardType.ReadOnly(), + type = type, amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", amountTextFieldValue = TextFieldValue( text = "0", @@ -484,11 +496,7 @@ internal class StateBuilder( enabled = false, onClick = actions.onSwapClick, ), - changeCardsButtonState = if (isReverseSwapPossible) { - ChangeCardsButtonState.ENABLED - } else { - ChangeCardsButtonState.DISABLED - }, + changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), providerState = providerState, priceImpact = PriceImpact.Empty(), tosState = createTosState(swapProvider), @@ -535,11 +543,13 @@ internal class StateBuilder( } } + @Suppress("LongParameterList") fun createQuotesEmptyAmountState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, fromTokenStatus: CryptoCurrencyStatus, toTokenStatus: CryptoCurrencyStatus?, + toAccount: Account.CryptoPortfolio?, isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -560,7 +570,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,11 +593,7 @@ internal class StateBuilder( enabled = false, onClick = { }, ), - changeCardsButtonState = if (isReverseSwapPossible) { - ChangeCardsButtonState.ENABLED - } else { - ChangeCardsButtonState.DISABLED - }, + changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty(), ) @@ -614,17 +622,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 +660,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 +719,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 +839,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), @@ -828,6 +858,7 @@ internal class StateBuilder( error: SwapTransactionState.Error, onDismiss: () -> Unit, onSupportClick: (String) -> Unit, + isReverseSwapPossible: Boolean, ): SwapStateHolder { val errorAlert = SwapTransactionErrorStateConverter( onSupportClick = onSupportClick, @@ -840,26 +871,32 @@ internal class StateBuilder( onConsume = onDismiss, ) } ?: consumedEvent(), - changeCardsButtonState = ChangeCardsButtonState.ENABLED, + changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), ) } - fun createDemoModeAlert(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder { + fun createDemoModeAlert( + uiState: SwapStateHolder, + onDismiss: () -> Unit, + isReverseSwapPossible: Boolean, + ): SwapStateHolder { return uiState.copy( event = triggeredEvent( data = SwapEvent.ShowAlert(AlertDemoModeUM(onDismiss)), onConsume = onDismiss, ), - changeCardsButtonState = ChangeCardsButtonState.ENABLED, + changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), ) } + @Suppress("LongParameterList") fun createAlert( uiState: SwapStateHolder, isPriceImpact: Boolean, token: String, provider: SwapProvider, onDismiss: () -> Unit, + isReverseSwapPossible: Boolean, ): SwapStateHolder { val slippage = provider.slippage?.let { "${it.parseBigDecimal(1)}$PERCENT" } val combinedMessage = buildList { @@ -906,7 +943,7 @@ internal class StateBuilder( ), onConsume = onDismiss, ), - changeCardsButtonState = ChangeCardsButtonState.ENABLED, + changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), ) } @@ -1327,6 +1364,36 @@ 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 fun getChangeCardsButtonState(isReverseSwapPossible: Boolean) = if (isReverseSwapPossible) { + ChangeCardsButtonState.ENABLED + } else { + ChangeCardsButtonState.DISABLED + } + 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..486c515d86 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 = "", @@ -456,6 +461,7 @@ private val state = SwapStateHolder( onClick = {}, ), ), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, ) @Preview(widthDp = 360, showBackground = true) 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/TangemPayConstants.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayConstants.kt new file mode 100644 index 0000000000..5a9903bfd6 --- /dev/null +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayConstants.kt @@ -0,0 +1,5 @@ +package com.tangem.features.tangempay + +object TangemPayConstants { + const val TERMS_AND_LIMITS_LINK = "https://tangem.com/docs/en/tangem-visa-tariffs.pdf" +} \ No newline at end of file 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/detekt-baseline-debug.xml b/features/tangempay/details/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..8e67fe2685 --- /dev/null +++ b/features/tangempay/details/impl/detekt-baseline-debug.xml @@ -0,0 +1,28 @@ + + + + + BooleanPropertyNaming:PinCodeChangeTransformer.kt$PinCodeChangeTransformer$val valid = PinCodeValidation.validate(pinCode = newPin) + BooleanPropertyNaming:TangemPayAddToWalletUM.kt$TangemPayAddToWalletUM$val showAddToWalletButton: Boolean + BooleanPropertyNaming:TangemPayChangePinCodeSuccessScreen.kt$var showConfetti by remember { mutableStateOf(false) } + BooleanPropertyNaming:TangemPayChangePinUM.kt$TangemPayChangePinUM$val submitButtonEnabled: Boolean + BooleanPropertyNaming:TangemPayChangePinUM.kt$TangemPayChangePinUM$val submitButtonLoading: Boolean + BooleanPropertyNaming:TangemPayDetailsScreen.kt$var showDropdownMenu by rememberSaveable { mutableStateOf(false) } + BooleanPropertyNaming:TangemPayDetailsUM.kt$TangemPayDetailsUM$val addFundsEnabled: Boolean + BooleanPropertyNaming:TangemPayTxHistoryListManager.kt$TangemPayTxHistoryListManager$val clearUiBatches = state.status is PaginationStatus.InitialLoading && batchListState.status is PaginationStatus.Paginating + CanBeNonNullable:TangemPayTxHistoryDetailsModel.kt$TangemPayTxHistoryDetailsModel$txHash: String? + MaxChainedCallsOnSameLine:TangemPayTxHistoryUiManager.kt$TangemPayTxHistoryUiManager$prevBatch?.data?.lastOrNull()?.date?.millis?.toDateFormatWithTodayYesterday() + MultilineLambdaItParameter:TangemPayAddFundsContent.kt${ key(it.title) { TangemPayTopUpItem(state = it) } } + MultilineLambdaItParameter:TangemPayDetailsScreen.kt${ TangemDropdownItem( item = it.dropdownItem, dismissParent = { showDropdownMenu = false }, ) } + MultilineLambdaItParameter:TangemPayTxHistoryUiManager.kt$TangemPayTxHistoryUiManager${ it.status !is PaginationStatus.None && it.status !is PaginationStatus.InitialLoading && it.status !is PaginationStatus.InitialLoadingError } + NullCheckOnMutableProperty:GoogleWalletUtil.kt$GoogleWalletUtil$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 } } + NullableBooleanCheck:TangemPayDetailsModel.kt$TangemPayDetailsModel$cardDetailsRepository.isAddToWalletDone().getOrNull() ?: false + ReusedModifierInstance:DefaultTangemPayDetailsContainerComponent.kt$DefaultTangemPayDetailsContainerComponent$Content(modifier = modifier) + ReusedModifierInstance:TangemPayChangePinCodeSuccessScreen.kt$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, ) } + ReusedModifierInstance:TangemPayChangePinScreen.kt$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) } + ReusedModifierInstance:TangemPayChangePinScreen.kt$PinCode( modifier = modifier, value = state.pinCode, onValueChange = state.onPinCodeChange, focusRequester = focusRequester, ) + UnnecessaryLet:TangempayTxDetailsUi.kt$let { Label(state = state.labelState, modifier = Modifier.padding(top = 12.dp)) } + UnnecessaryLet:TangempayTxDetailsUi.kt$let { Notification( config = state.notification, titleColor = TangemTheme.colors.text.tertiary, iconTint = TangemTheme.colors.icon.secondary, ) } + UseEmptyCounterpart:TangemPayTxHistoryState.kt$TangemPayTxHistoryState$listOf() + + 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..126f7124d8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -0,0 +1,92 @@ +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), + params = params, + ) + 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..7b3ecc0c87 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt @@ -0,0 +1,32 @@ +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, + params: TangemPayDetailsContainerComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayChangePinModel = getOrCreateModel(params) + + @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..38d29ced5d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -0,0 +1,104 @@ +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( + userWalletId = params.userWalletId, + 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..95e5cae0d4 --- /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.TangemPayCard +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) { + TangemPayCard(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..8a4043c5ee --- /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.TangemPayCard +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) { + TangemPayCard(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..2714c0a662 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay.components.cardDetails + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import kotlinx.coroutines.flow.StateFlow + +@Stable +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..bfd618dfa5 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 @@ -4,9 +4,11 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.models.wallet.UserWalletId 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 +23,9 @@ internal class DefaultTangemPayTxHistoryComponent( tangemPayTxHistoryItems(listState, state) } - data class Params(val customerWalletAddress: String) + data class Params( + val userWalletId: UserWalletId, + 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..0d2e66653a --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -0,0 +1,24 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.details.impl.R +import com.tangem.utils.StringsSigns + +internal class TangemPayCardDetailsBlockStateFactory( + private val cardNumberEnd: String, + private val onReveal: () -> Unit, + private val onCopy: (String) -> Unit, +) { + + fun getInitialState() = TangemPayCardDetailsUM( + number = "${StringsSigns.ASTERISK}$cardNumberEnd", + expiry = "", + cvv = "", + buttonText = resourceReference(R.string.tangempay_card_details_reveal_text), + onClick = onReveal, + onCopy = onCopy, + isHidden = true, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + ) +} \ 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..1b882ec4a5 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 @@ -2,55 +2,86 @@ 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.extensions.TextReference +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.utils.CardDetailsFormatUtil -import com.tangem.utils.StringsSigns +import com.tangem.features.tangempay.model.transformers.TangemPayCardFrozenStateConverter +import com.tangem.features.tangempay.utils.TangemPayDetailIntents import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList -private const val CARD_NUMBER_SART_DIGITS_COUNT = 12 -private const val DATE_PART_LENGTH = 2 -private const val CVV_LENGTH = 3 - +@Suppress("LongParameterList") internal class TangemPayDetailsStateFactory( - private val cardNumberEnd: String, private val onBack: () -> Unit, - private val onRefresh: (ShowRefreshState) -> Unit, - private val onReceive: () -> Unit, - private val onReveal: () -> Unit, - private val onCopy: (String) -> Unit, + private val intents: TangemPayDetailIntents, + private val cardFrozenState: TangemPayCardFrozenState, + private val converter: TangemPayCardFrozenStateConverter, ) { - 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 { + val cardFrozenStateItem = when (cardFrozenState) { + is TangemPayCardFrozenState.Pending -> null + is TangemPayCardFrozenState.Frozen -> TangemPayDetailsTopBarMenuItem( + type = TangemPayDetailsTopBarMenuItemType.UnfreezeCard, + dropdownItem = TangemDropdownMenuItem( + title = resourceReference(R.string.tangempay_card_details_unfreeze_card), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onClickUnfreezeCard, + ), + ) + is TangemPayCardFrozenState.Unfrozen -> TangemPayDetailsTopBarMenuItem( + type = TangemPayDetailsTopBarMenuItemType.FreezeCard, + dropdownItem = TangemDropdownMenuItem( + title = resourceReference(R.string.tangempay_card_details_freeze_card), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onClickFreezeCard, + ), + ) + } - fun getInitialState() = TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = onBack, items = null), - pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = onRefresh), - balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( - actionButtons = persistentListOf( - ActionButtonConfig( - text = resourceReference(id = R.string.common_receive), - iconResId = R.drawable.ic_arrow_down_24, - onClick = onReceive, + return TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig( + onBackClick = onBack, + items = listOfNotNull( + TangemPayDetailsTopBarMenuItem( + type = TangemPayDetailsTopBarMenuItemType.ChangePin, + dropdownItem = TangemDropdownMenuItem( + title = resourceReference(R.string.tangempay_card_details_change_pin), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onClickChangePin, + ), + ), + TangemPayDetailsTopBarMenuItem( + type = TangemPayDetailsTopBarMenuItemType.TermsAndLimits, + dropdownItem = TangemDropdownMenuItem( + title = resourceReference(R.string.tangem_pay_terms_limits), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onClickTermsAndLimits, + ), + ), + cardFrozenStateItem, + ).toPersistentList(), + ), + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = intents::onRefreshSwipe, + ), + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( + actionButtons = persistentListOf( + ActionButtonConfig( + text = resourceReference(id = R.string.tangempay_card_details_add_funds), + iconResId = R.drawable.ic_arrow_down_24, + onClick = intents::onClickAddFunds, + ), ), ), - ), - 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, - ), - isBalanceHidden = false, - - ) - - private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count) + addToWalletBlockState = null, + isBalanceHidden = false, + addFundsEnabled = true, + cardFrozenState = converter.convert(cardFrozenState), + ) + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index 85908dce5c..1010496a66 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -5,5 +5,17 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, - val items: ImmutableList?, -) \ No newline at end of file + val items: ImmutableList?, +) + +internal data class TangemPayDetailsTopBarMenuItem( + val type: TangemPayDetailsTopBarMenuItemType, + val dropdownItem: TangemDropdownMenuItem, +) + +internal enum class TangemPayDetailsTopBarMenuItemType { + ChangePin, + TermsAndLimits, + FreezeCard, + UnfreezeCard, +} \ 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..3923a92fe7 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 @@ -3,25 +3,29 @@ 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.extensions.TextReference +import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.collections.immutable.ImmutableList 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, + val cardFrozenState: CardFrozenState, ) -data class TangemPayCardDetailsUM( - val number: String = "", - val expiry: String = "", - val cvv: String = "", +internal data class TangemPayCardDetailsUM( + val number: String, + val expiry: String, + val cvv: String, val buttonText: TextReference = TextReference.EMPTY, val onClick: () -> Unit = {}, val onCopy: (String) -> Unit = {}, val isHidden: Boolean = true, val isLoading: Boolean = false, + val cardFrozenState: TangemPayCardFrozenState, ) internal sealed class TangemPayDetailsBalanceBlockState { @@ -42,4 +46,15 @@ internal sealed class TangemPayDetailsBalanceBlockState { data class Error( override val actionButtons: ImmutableList, ) : TangemPayDetailsBalanceBlockState() -} \ No newline at end of file +} + +sealed class CardFrozenState { + data object Pending : CardFrozenState() + data class Frozen(val onUnfreeze: () -> Unit) : CardFrozenState() + data object Unfrozen : CardFrozenState() +} + +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..d6d716c254 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt @@ -0,0 +1,25 @@ +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 localTransactionText: String?, + 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..5c83a87360 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -0,0 +1,121 @@ +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.TangemPayCardDetailsRepository +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 kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import com.tangem.utils.transformer.update as transformerUpdate + +private const val SHOW_DETAILS_TIME = 30_000L + +@Stable +@ModelScoped +internal class TangemPayCardDetailsBlockModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + 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() + private val showCardDetailsTimerJobHolder = JobHolder() + + init { + subscribeToCardFrozenState() + modelScope.launch { + cardDetailsEventListener.event.collectLatest { event -> + when (event) { + CardDetailsEvent.Hide -> hideCardDetails() + CardDetailsEvent.Show -> revealCardDetails() + } + } + } + } + + private fun subscribeToCardFrozenState() { + cardDetailsRepository + .cardFrozenState(params.params.config.cardId) + .onEach { uiState.update { state -> state.copy(cardFrozenState = it) } } + .launchIn(modelScope) + } + + private fun revealCardDetails() { + modelScope.launch { + uiState.transformerUpdate( + transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), + ) + cardDetailsRepository.revealCardDetails(params.params.userWalletId) + .onRight { cardDetails -> + uiState.transformerUpdate( + transformer = DetailsRevealedStateTransformer( + details = cardDetails, + onClickHide = ::hideCardDetails, + ), + ) + launchShowDetailsTimer() + } + .onLeft { + uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory)) + showError() + } + }.saveIn(revealCardDetailsJobHolder) + } + + private fun launchShowDetailsTimer() { + modelScope.launch { + delay(SHOW_DETAILS_TIME) + hideCardDetails() + }.saveIn(showCardDetailsTimerJobHolder) + } + + fun hideCardDetails() { + modelScope.launch { + revealCardDetailsJobHolder.cancel() + uiState.transformerUpdate(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..405a45ad13 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -0,0 +1,72 @@ +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.navigation.Router +import com.tangem.domain.pay.model.SetPinResult +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.entity.TangemPayChangePinUM +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.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayChangePinModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val cardDetailsRepository: TangemPayCardDetailsRepository, +) : Model() { + + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + 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 = try { + cardDetailsRepository.setPin( + userWalletId = params.userWalletId, + pin = uiState.value.pinCode, + ).getOrNull() + } catch (e: Exception) { + Timber.e(e) + return@launch + } + 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..1f9565d831 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,43 @@ 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.navigation.url.UrlOpener import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.TokenReceiveConfig -import com.tangem.domain.models.TokenReceiveType -import com.tangem.domain.pay.DataForReceiveFactory -import com.tangem.domain.pay.repository.CardDetailsRepository -import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.domain.pay.TangemPayTopUpData +import com.tangem.domain.pay.model.TangemPayCardBalance +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.tangempay.TangemPayConstants +import com.tangem.features.tangempay.components.AddFundsListener +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.model.listener.CardDetailsEvent +import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.transformers.* -import com.tangem.features.tangempay.utils.TangemPayErrorMessageFactory +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.utils.TangemPayDetailIntents +import com.tangem.features.tangempay.utils.TangemPayMessagesFactory +import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions +import com.tangem.features.tangempay.utils.TangemPayTxHistoryUpdateListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -33,15 +45,12 @@ import com.tangem.utils.transformer.update import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import timber.log.Timber 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 @@ -49,21 +58,22 @@ internal class TangemPayDetailsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, - private val cardDetailsRepository: CardDetailsRepository, - private val dataForReceiveFactory: DataForReceiveFactory, - private val clipboardManager: ClipboardManager, + private val urlOpener: UrlOpener, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val uiMessageSender: UiMessageSender, -) : Model() { + private val cardDetailsEventListener: CardDetailsEventListener, + private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener, +) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener { - private val params: TangemPayDetailsComponent.Params = paramsContainer.require() + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + private val cardFrozenStateConverter = TangemPayCardFrozenStateConverter(onUnfreezeClick = ::onClickUnfreezeCard) private val stateFactory = TangemPayDetailsStateFactory( - cardNumberEnd = params.config.cardNumberEnd, onBack = router::pop, - onRefresh = ::onRefreshSwipe, - onReceive = ::onClickReceive, - onReveal = ::revealCardDetails, - onCopy = ::copyData, + intents = this, + cardFrozenState = params.config.cardFrozenState, + converter = cardFrozenStateConverter, ) val uiState: StateFlow @@ -71,96 +81,249 @@ 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 { + handleBalanceHiding() + fetchAddToWalletBanner() fetchBalance() + subscribeToCardFrozenState() } - private fun onClickReceive() { - val depositAddress = params.config.depositAddress - if (depositAddress == null) { - showError() - } 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)) - } + private fun subscribeToCardFrozenState() { + cardDetailsRepository + .cardFrozenState(params.config.cardId) + .onEach { state -> + uiState.update( + TangemPayFreezeUnfreezeStateTransformer( + cardFrozenState = state, + onFreezeClick = ::onClickFreezeCard, + onUnfreezeClick = ::onClickUnfreezeCard, + converter = cardFrozenStateConverter, + ), + ) + } + .launchIn(modelScope) + } + + override fun onClickChangePin() { + router.push(TangemPayDetailsInnerRoute.ChangePIN) + } + + override fun onClickFreezeCard() { + uiMessageSender.send(TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard)) + } + + override fun onClickUnfreezeCard() { + uiMessageSender.send(TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard)) + } + + private fun freezeCard() { + modelScope.launch { + val result = try { + cardDetailsRepository.freezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) + } catch (e: Exception) { + Timber.e(e) + return@launch + } + result .onLeft { - val messageUM = TangemPayErrorMessageFactory.createError( - type = TangemPayDetailsErrorType.Receive, - onDismiss = bottomSheetNavigation::dismiss, - ) - bottomSheetNavigation.activate(TangemPayDetailsNavigation.Error(messageUM)) + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed))) } + .onRight { state -> + when (state) { + TangemPayCardFrozenState.Frozen -> { + uiMessageSender.send( + SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)), + ) + uiState.update( + TangemPayFreezeUnfreezeStateTransformer( + cardFrozenState = state, + onFreezeClick = ::onClickFreezeCard, + onUnfreezeClick = ::onClickUnfreezeCard, + converter = cardFrozenStateConverter, + ), + ) + } + TangemPayCardFrozenState.Unfrozen -> { + uiMessageSender.send( + SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)), + ) + } + TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA] + } + } + } + } + + private fun unfreezeCard() { + modelScope.launch { + val result = try { + cardDetailsRepository.unfreezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) + } catch (e: Exception) { + Timber.e(e) + return@launch + } + result + .onLeft { + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed))) + } + .onRight { state -> + when (state) { + TangemPayCardFrozenState.Unfrozen -> { + uiMessageSender.send( + SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)), + ) + uiState.update( + TangemPayFreezeUnfreezeStateTransformer( + cardFrozenState = state, + onFreezeClick = ::onClickFreezeCard, + onUnfreezeClick = ::onClickUnfreezeCard, + converter = cardFrozenStateConverter, + ), + ) + } + TangemPayCardFrozenState.Frozen -> { + uiMessageSender.send( + SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)), + ) + } + TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA] + } + } + } + } + + override fun onClickAddFunds() { + if (params.config.depositAddress == null || balance == null) { + showBottomSheetError(TangemPayDetailsErrorType.Receive) + } else { + 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 = try { + cardDetailsRepository.getCardBalance(params.userWalletId).onRight { balance = it } + } catch (e: Exception) { + Timber.e(e) + return@launch + } + uiState.update(DetailsBalanceTransformer(balance = result)) }.saveIn(fetchBalanceJobHolder) } - private fun onRefreshSwipe(refreshState: ShowRefreshState) { + private fun handleBalanceHiding() { + getBalanceHidingSettingsUseCase().onEach { + uiState.update(DetailBalanceVisibilityTransformer(isHidden = it.isBalanceHidden)) + }.launchIn(modelScope) + } + + private fun fetchAddToWalletBanner() { + modelScope.launch { + val isDone = try { + cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true + } catch (e: Exception) { + Timber.e(e) + return@launch + } + uiState.update( + transformer = DetailsAddToWalletBannerTransformer( + onClickBanner = ::onClickAddToWalletBlock, + onClickCloseBanner = ::onClickCloseAddToWalletBlock, + isDone = isDone, + ), + ) + }.saveIn(addToWalletBannerJobHolder) + } + + override fun onRefreshSwipe(refreshState: ShowRefreshState) { modelScope.launch { - hideCardDetails() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value)) + cardDetailsEventListener.send(CardDetailsEvent.Hide) + txHistoryUpdateListener.triggerUpdate() fetchBalance().join() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) }.saveIn(refreshStateJobHolder) } - private fun revealCardDetails() { + private fun onClickAddToWalletBlock() { + router.push(TangemPayDetailsInnerRoute.AddToWallet) + } + + private fun onClickCloseAddToWalletBlock() { modelScope.launch { + try { + cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) + } catch (e: Exception) { + Timber.e(e) + } 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)) + } + + override fun onClickTermsAndLimits() { + urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) + } + + private fun showBottomSheetError(type: TangemPayDetailsErrorType) { + uiMessageSender.send(message = TangemPayMessagesFactory.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..e1433f4d17 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,34 +6,33 @@ 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.features.tangempay.utils.TangemPayTxHistoryUpdateListener 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 @ModelScoped internal class TangemPayTxHistoryModel @Inject constructor( - private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - override val dispatchers: CoroutineDispatcherProvider, - tangemPayTxHistoryRepository: TangemPayTxHistoryRepository, paramsContainer: ParamsContainer, -) : Model(), TangemPayTxHistoryUiActions { + tangemPayTxHistoryRepository: TangemPayTxHistoryRepository, + override val dispatchers: CoroutineDispatcherProvider, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener, +) : 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 @@ -43,10 +42,11 @@ internal class TangemPayTxHistoryModel @Inject constructor( handleBalanceHiding() launchPagination() subscribeToUiItemChanges() + subscribeToUpdateListener() } private fun launchPagination() { - modelScope.launch { listManager.launchPagination() } + modelScope.launch { listManager.launchPagination(params.userWalletId) } } private fun subscribeToUiItemChanges() { @@ -61,6 +61,12 @@ internal class TangemPayTxHistoryModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeToUpdateListener() { + txHistoryUpdateListener.updates + .onEach { reload() } + .launchIn(modelScope) + } + private fun updateState(items: ImmutableList) { if (items.isEmpty()) return // fast exit. If items is empty, no need to update ui items @@ -115,10 +121,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/DetailBalanceVisibilityTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailBalanceVisibilityTransformer.kt new file mode 100644 index 0000000000..02b03a3a14 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailBalanceVisibilityTransformer.kt @@ -0,0 +1,13 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class DetailBalanceVisibilityTransformer( + private val isHidden: Boolean, +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + return prevState.copy(isBalanceHidden = isHidden) + } +} \ 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/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index 61b1413c22..90d3d95786 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -18,7 +18,7 @@ internal class DetailsBalanceTransformer( override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { val balance = when (balance) { is Either.Left -> { - TangemPayDetailsBalanceBlockState.Error(persistentListOf()) + TangemPayDetailsBalanceBlockState.Error(actionButtons = persistentListOf()) } is Either.Right -> { TangemPayDetailsBalanceBlockState.Content( 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..0856ea376b 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().copy(cardFrozenState = prevState.cardFrozenState) } } \ 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..b4850f2707 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 @@ -1,28 +1,39 @@ package com.tangem.features.tangempay.model.transformers -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference 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 +private const val CARD_NUMBER_CHUNK_LENGTH = 4 +private const val DATE_LENGTH = 2 + internal class DetailsRevealedStateTransformer( private val details: TangemPayCardDetails, private val onClickHide: (() -> Unit), -) : Transformer { +) : Transformer { - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val cardDetailsUM = TangemPayCardDetailsUM( - number = CardDetailsFormatUtil.formatCardNumber(cardNumber = details.pan), - expiry = CardDetailsFormatUtil.formatDate(month = details.expirationMonth, year = details.expirationYear), + override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM { + return prevState.copy( + number = formatCardNumber(cardNumber = details.pan), + expiry = 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, + buttonText = resourceReference(R.string.tangempay_card_details_hide_text), isHidden = false, ) - return prevState.copy(cardDetailsUM = cardDetailsUM) + } + + private fun formatCardNumber(cardNumber: String): String { + return cardNumber + .filterNot { it.isWhitespace() } + .chunked(CARD_NUMBER_CHUNK_LENGTH).joinToString(" ") + } + + private fun formatDate(month: String, year: String): String { + val monthFormatted = month.padStart(DATE_LENGTH, '0') + val yearFormatted = year.takeLast(DATE_LENGTH) + return "$monthFormatted/$yearFormatted" } } \ 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..3206ef6bc0 --- /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.TangemPayMessagesFactory +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 = TangemPayMessagesFactory.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/TangemPayCardFrozenStateConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt new file mode 100644 index 0000000000..1be3c5e765 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.entity.CardFrozenState +import com.tangem.utils.converter.Converter + +internal class TangemPayCardFrozenStateConverter( + private val onUnfreezeClick: () -> Unit, +) : Converter { + + override fun convert(value: TangemPayCardFrozenState): CardFrozenState { + return when (value) { + TangemPayCardFrozenState.Unfrozen -> CardFrozenState.Unfrozen + TangemPayCardFrozenState.Frozen -> CardFrozenState.Frozen(onUnfreezeClick) + TangemPayCardFrozenState.Pending -> CardFrozenState.Pending + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt new file mode 100644 index 0000000000..eb4dc8c81e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt @@ -0,0 +1,68 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItem +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.FreezeCard +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.UnfreezeCard +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +internal class TangemPayFreezeUnfreezeStateTransformer( + private val cardFrozenState: TangemPayCardFrozenState, + private val onFreezeClick: () -> Unit, + private val onUnfreezeClick: () -> Unit, + private val converter: TangemPayCardFrozenStateConverter, +) : Transformer { + + private val isCardFrozen: Boolean = when (cardFrozenState) { + is TangemPayCardFrozenState.Frozen -> true + is TangemPayCardFrozenState.Unfrozen, is TangemPayCardFrozenState.Pending -> false + } + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + val dropdownMenuItems = when (cardFrozenState) { + TangemPayCardFrozenState.Frozen, + TangemPayCardFrozenState.Unfrozen, + -> createUpdatedMenuItems(prevState.topBarConfig.items) + TangemPayCardFrozenState.Pending -> prevState.topBarConfig.items + } + + return prevState.copy( + topBarConfig = prevState.topBarConfig.copy(items = dropdownMenuItems), + cardFrozenState = converter.convert(cardFrozenState), + ) + } + + private fun createUpdatedMenuItems( + items: ImmutableList?, + ): ImmutableList? { + return items + ?.filterNot { it.type == UnfreezeCard || it.type == FreezeCard } + ?.plus(createMenuItemToAdd()) + ?.toPersistentList() + } + + private fun createMenuItemToAdd(): TangemPayDetailsTopBarMenuItem { + return TangemPayDetailsTopBarMenuItem( + type = if (isCardFrozen) UnfreezeCard else FreezeCard, + dropdownItem = TangemDropdownMenuItem( + title = resourceReference( + id = if (isCardFrozen) { + R.string.tangempay_card_details_unfreeze_card + } else { + R.string.tangempay_card_details_freeze_card + }, + ), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = if (isCardFrozen) onUnfreezeClick else onFreezeClick, + ), + ) + } +} \ 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..69fd729e59 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverter.kt @@ -0,0 +1,247 @@ +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.format.bigdecimal.price +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.isZero +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 = transaction.extractAmountColor(), + localTransactionText = transaction.extractLocalAmount(), + labelState = transaction.extractLabel(), + notification = transaction.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 -> 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.Spend -> stringReference(this.enrichedMerchantName ?: this.merchantName) + is TangemPayTxHistoryItem.Payment -> resourceReference(R.string.tangem_pay_withdrawal) + is TangemPayTxHistoryItem.Collateral -> resourceReference(R.string.tangem_pay_deposit) + is TangemPayTxHistoryItem.Fee -> { + this.description?.let(::stringReference) ?: resourceReference(R.string.tangem_pay_fee_title) + } + } + } + + 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.Collateral -> resourceReference(R.string.common_transfer) + is TangemPayTxHistoryItem.Spend -> { + val subtitle = merchantCategory ?: enrichedMerchantCategory + subtitle?.let(::stringReference) ?: resourceReference(R.string.tangem_pay_other) + } + } + } + + private fun TangemPayTxHistoryItem.extractAmount(): String { + return when (this) { + is TangemPayTxHistoryItem.Fee, + is TangemPayTxHistoryItem.Payment, + -> { + val amount = this.amount.format { + fiat( + fiatCurrencyCode = this@extractAmount.currency.currencyCode, + fiatCurrencySymbol = this@extractAmount.currency.symbol, + ) + } + StringsSigns.MINUS + amount + } + is TangemPayTxHistoryItem.Spend -> { + val amountPrefix = when { + this.amount.isZero() -> "" + this.status == TangemPayTxHistoryItem.Status.DECLINED -> "" + else -> StringsSigns.MINUS + } + val amount = this.amount.format { + fiat( + fiatCurrencyCode = this@extractAmount.currency.currencyCode, + fiatCurrencySymbol = this@extractAmount.currency.symbol, + ) + } + amountPrefix + amount + } + is TangemPayTxHistoryItem.Collateral -> { + val amount = this.amount.format { + fiat( + fiatCurrencyCode = this@extractAmount.currency.currencyCode, + fiatCurrencySymbol = this@extractAmount.currency.symbol, + ) + } + StringsSigns.PLUS + amount + } + } + } + + private fun TangemPayTxHistoryItem.extractAmountColor(): ColorReference { + return when (this) { + is TangemPayTxHistoryItem.Fee, + is TangemPayTxHistoryItem.Spend, + is TangemPayTxHistoryItem.Payment, + -> themedColor { TangemTheme.colors.text.primary1 } + is TangemPayTxHistoryItem.Collateral -> themedColor { TangemTheme.colors.text.accent } + } + } + + private fun TangemPayTxHistoryItem.extractLocalAmount(): String? { + return when (this) { + is TangemPayTxHistoryItem.Collateral, + is TangemPayTxHistoryItem.Fee, + is TangemPayTxHistoryItem.Payment, + -> null + is TangemPayTxHistoryItem.Spend -> { + val localCurrency = this.localCurrency + val localAmount = this.localAmount + if (localCurrency != null && localAmount != null && localCurrency != currency) { + localAmount.format { + fiat( + fiatCurrencyCode = localCurrency.currencyCode, + fiatCurrencySymbol = localCurrency.symbol, + ).price() + } + } else { + null + } + } + } + } + + 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_get_help), + onClick = this.onDisputeClick, + ), + ) + is TangemPayTxHistoryItem.Collateral -> persistentListOf( + 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..ef6172f0c3 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,20 @@ 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,71 +25,88 @@ 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 { fiat(fiatCurrencyCode = spend.currency.currencyCode, fiatCurrencySymbol = spend.currency.symbol) } + val subtitle = spend.merchantCategory ?: spend.enrichedMerchantCategory return TangemPayTransactionState.Content.Spend( 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 } }, title = stringReference(spend.enrichedMerchantName ?: spend.merchantName), - subtitle = stringReference(spend.enrichedMerchantCategory ?: spend.merchantCategory), + subtitle = subtitle?.let(::stringReference) ?: resourceReference(R.string.tangem_pay_other), 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 amount = amountPrefix + payment.amount.format { + val amount = StringsSigns.MINUS + payment.amount.format { fiat(fiatCurrencyCode = payment.currency.currencyCode, fiatCurrencySymbol = payment.currency.symbol) } - val title = if (payment.amount.isPositive()) "Deposit" else "Withdrawal" return TangemPayTransactionState.Content.Payment( id = payment.id, onClick = { txHistoryUiActions.onTransactionClick(payment) }, amount = amount, - amountColor = { - if (payment.amount.isPositive()) { - TangemTheme.colors.text.accent - } else { - TangemTheme.colors.text.primary1 - } - }, - title = stringReference(title), + amountColor = themedColor { TangemTheme.colors.text.primary1 }, + title = resourceReference(R.string.tangem_pay_withdrawal), subtitle = stringReference("Transfers"), time = DateTimeFormatters.formatDate(payment.date, DateTimeFormatters.timeFormatter), - isIncome = payment.amount.isPositive(), + icon = ImageReference.Res(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 }, - title = stringReference("Fee"), - subtitle = stringReference("Service fees"), + amountColor = themedColor { TangemTheme.colors.text.primary1 }, + title = resourceReference(R.string.tangem_pay_fee_title), + subtitle = fee.description?.let(::stringReference) ?: resourceReference(R.string.tangem_pay_fee_subtitle), + 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/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..5abb62dfac --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddFundsContent.kt @@ -0,0 +1,155 @@ +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 androidx.compose.ui.util.fastForEach +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.fastForEach { + 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..c8faca06da --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -0,0 +1,197 @@ +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.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.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(TangemPayCardDetailsUM::class.java) { + cardDetailsBlockComponent.CardDetailsBlockContent( + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp), + 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), + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + ), + ), + ) + } +} \ 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..2370490f90 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -0,0 +1,322 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +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.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM + +@Suppress("MagicNumber") +@Composable +internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + val isCardFlipped = !state.isHidden + val animDuration = 900 + val zAxisDistance = 50f // distance between camera and Card + + // rotate Y-axis with animation + val rotateCardY by animateFloatAsState( + targetValue = if (isCardFlipped) 180f else 0f, + animationSpec = tween(durationMillis = animDuration, easing = EaseInOut), + label = "", + ) + + // Determine which side to show based on the rotation angle + val shouldShowDetails = rotateCardY > 90f + + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(328f / 212f) // size of img_tangem_pay_visa + .graphicsLayer { + rotationY = rotateCardY + cameraDistance = zAxisDistance + } + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = state.onClick) + .background(Color(red = 18, green = 21, blue = 31)) + .border( + width = 1.dp, + brush = Brush.linearGradient( + colors = listOf( + TangemTheme.colors.text.constantWhite.copy(alpha = 0.1F), + TangemTheme.colors.text.constantWhite.copy(alpha = 0f), + ), + ), + shape = RoundedCornerShape(16.dp), + ), + ) { + if (shouldShowDetails) { + TangemPayCardDetailsShownBlock( + cardNumber = state.number, + expiry = state.expiry, + cvv = state.cvv, + onCopyCardNumber = { state.onCopy(state.number) }, + onCopyCvv = { state.onCopy(state.cvv) }, + onCopyExpiry = { state.onCopy(state.expiry) }, + onHideDetails = state.onClick, + modifier = Modifier.graphicsLayer { rotationY = 180f }, + ) + } else { + TangemPayCardDetailsHiddenBlock( + cardFrozenState = state.cardFrozenState, + isLoading = state.isLoading, + shortCardNumber = state.number, + onShowDetails = state.onClick, + ) + } + } +} + +@Composable +private fun TangemPayCardDetailsHiddenBlock( + shortCardNumber: String, + cardFrozenState: TangemPayCardFrozenState, + isLoading: Boolean, + onShowDetails: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + val imageResId = when (cardFrozenState) { + is TangemPayCardFrozenState.Frozen -> R.drawable.img_tangem_pay_visa_frozen + else -> R.drawable.img_tangem_pay_visa + } + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(id = imageResId), + contentDescription = null, + ) + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = shortCardNumber, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.constantWhite, + ) + when (cardFrozenState) { + is TangemPayCardFrozenState.Frozen -> Icon( + modifier = Modifier + .padding(start = 4.dp) + .size(16.dp), + painter = painterResource(id = R.drawable.ic_snow_24), + contentDescription = null, + tint = TangemTheme.colors.icon.constant, + ) + TangemPayCardFrozenState.Pending -> CircularProgressIndicator( + modifier = Modifier + .padding(start = 4.dp) + .size(16.dp), + color = TangemTheme.colors.text.constantWhite, + strokeWidth = 1.dp, + ) + TangemPayCardFrozenState.Unfrozen -> Unit + } + SpacerWMax() + PrimaryButton( + text = stringResourceSafe(id = R.string.tangempay_card_details_show_details), + showProgress = isLoading, + onClick = onShowDetails, + size = TangemButtonSize.Small, + ) + } + } +} + +@Suppress("MagicNumber", "LongParameterList") +@Composable +private fun TangemPayCardDetailsShownBlock( + cardNumber: String, + expiry: String, + cvv: String, + onCopyCardNumber: () -> Unit, + onCopyExpiry: () -> Unit, + onCopyCvv: () -> Unit, + onHideDetails: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(8.dp) + .fillMaxSize(), + ) { + CardDetailsTextContainer( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + title = stringResourceSafe(R.string.tangempay_card_details_card_number), + text = cardNumber, + onCopy = onCopyCardNumber, + ) + HorizontalDivider(thickness = 1.dp, color = Color(0x1F828282)) // Hardcode since background is an image + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CardDetailsTextContainer( + modifier = Modifier + .weight(1f) + .wrapContentHeight(), + title = stringResourceSafe(R.string.tangempay_card_details_expiry), + text = expiry, + onCopy = onCopyExpiry, + ) + VerticalDivider(thickness = 1.dp, color = Color(0x1F828282)) // Hardcode since background is an image + CardDetailsTextContainer( + modifier = Modifier + .weight(1f) + .wrapContentHeight(), + title = stringResourceSafe(R.string.tangempay_card_details_cvc), + text = cvv, + onCopy = onCopyCvv, + ) + } + Row { + SpacerWMax() + PrimaryButton( + text = stringResourceSafe(id = R.string.tangempay_card_details_hide_details), + onClick = onHideDetails, + size = TangemButtonSize.Small, + ) + } + } +} + +@Composable +private fun CardDetailsTextContainer(title: String, text: String, onCopy: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier = modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + Text( + text = text, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.constantWhite, + ) + } + IconButton( + modifier = Modifier + .padding(end = TangemTheme.dimens.spacing12) + .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, + ) + } + } +} + +@Preview(widthDp = 400, heightDp = 700, showBackground = true) +@Composable +private fun TangemPayCardDetailsBlockV2Preview( + @PreviewParameter(TangemPayCardDetailsUMProvider::class) state: TangemPayCardDetailsUM, +) { + TangemThemePreview(isDark = true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary), + ) { + TangemPayCard( + state = state, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp), + ) + } + } +} + +private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + TangemPayCardDetailsUM( + isLoading = false, + onClick = {}, + number = "*1245", + expiry = "", + cvv = "", + buttonText = resourceReference(R.string.tangempay_card_details_show_details), + onCopy = {}, + isHidden = true, + cardFrozenState = TangemPayCardFrozenState.Frozen, + ), + TangemPayCardDetailsUM( + isLoading = false, + onClick = {}, + number = "*1245", + expiry = "", + cvv = "", + buttonText = resourceReference(R.string.tangempay_card_details_show_details), + onCopy = {}, + isHidden = true, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + ), + TangemPayCardDetailsUM( + isLoading = false, + onClick = {}, + number = "*1245", + expiry = "", + cvv = "", + buttonText = resourceReference(R.string.tangempay_card_details_show_details), + onCopy = {}, + isHidden = true, + cardFrozenState = TangemPayCardFrozenState.Pending, + ), + TangemPayCardDetailsUM( + isLoading = false, + onClick = {}, + buttonText = resourceReference(R.string.tangempay_card_details_hide_details), + onCopy = {}, + isHidden = false, + number = "1234 5678 9012 3456", + expiry = "12/34", + cvv = "123", + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + ), + ), +) \ 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..d55d82db18 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,17 @@ 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.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.setValue 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 @@ -26,6 +23,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.buttons.HorizontalActionChips import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig @@ -33,12 +31,17 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu -import com.tangem.core.ui.components.snackbar.TangemSnackbarHost import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R @@ -46,30 +49,24 @@ 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 - +@Suppress("LongMethod") @Composable internal fun TangemPayDetailsScreen( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, + cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, modifier: Modifier = Modifier, - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { Scaffold( modifier = modifier, topBar = { TangemPayDetailsTopAppBar(config = state.topBarConfig) }, contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), containerColor = TangemTheme.colors.background.secondary, - snackbarHost = { - TangemSnackbarHost( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - hostState = snackbarHostState, - ) - }, ) { scaffoldPaddings -> 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, @@ -82,31 +79,52 @@ internal fun TangemPayDetailsScreen( bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, ), ) { + item(TangemPayCardDetailsUM::class.java) { + cardDetailsBlockComponent.CardDetailsBlockContent( + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp), + state = cardDetailsState, + ) + } + when (state.cardFrozenState) { + is CardFrozenState.Frozen -> item(CardFrozenState.Frozen::class.java) { + PrimaryButton( + modifier = Modifier + .animateItem() + .padding(horizontal = 16.dp) + .padding(top = 12.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.tangempay_card_details_unfreeze_card), + onClick = state.cardFrozenState.onUnfreeze, + ) + } + else -> Unit + } item( key = TangemPayDetailsBalanceBlockState::class.java, content = { TangemPayDetailsBalanceBlock( modifier = modifier .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(top = 12.dp) .fillMaxWidth(), state = state.balanceBlockState, isBalanceHidden = state.isBalanceHidden, ) }, ) - 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), + ) + }, + ) + } with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } } } @@ -135,7 +153,7 @@ internal fun TangemPayDetailsBalanceBlock( style = TangemTheme.typography.subtitle2, ) FiatBalance( - modifier = Modifier.padding(start = 12.dp, top = 4.dp), + modifier = Modifier.padding(start = 12.dp, top = 8.dp), state = state, isBalanceHidden = isBalanceHidden, ) @@ -218,136 +236,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) { @@ -383,7 +271,7 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi content = { config.items?.fastForEach { TangemDropdownItem( - item = it, + item = it.dropdownItem, dismissParent = { showDropdownMenu = false }, ) } @@ -411,6 +299,17 @@ 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), + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + ), + ), ) } } @@ -432,29 +331,19 @@ 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..f9534f5d05 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUi.kt @@ -0,0 +1,287 @@ +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 + +@Suppress("LongMethod") +@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.localTransactionText?.let { localTransaction -> + Text( + modifier = Modifier.padding(top = 4.dp), + text = localTransaction, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + 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 }, + localTransactionText = null, + 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 }, + localTransactionText = null, + 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 }, + localTransactionText = "€ 5.36", + 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 }, + localTransactionText = null, + 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 }, + localTransactionText = null, + labelState = null, + notification = null, + buttons = persistentListOf( + TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_get_help), + 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 }, + localTransactionText = null, + labelState = null, + notification = null, + buttons = persistentListOf( + 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/CardDetailsFormatUtil.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/CardDetailsFormatUtil.kt deleted file mode 100644 index 248ae838a0..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/CardDetailsFormatUtil.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.features.tangempay.utils - -internal object CardDetailsFormatUtil { - - private const val CARD_NUMBER_CHUNK_LENGTH = 4 - private const val DATE_LENGTH = 2 - - fun formatCardNumber(cardNumber: String): String { - return cardNumber - .filterNot { it.isWhitespace() } - .chunked(CARD_NUMBER_CHUNK_LENGTH).joinToString(" ") - } - - fun formatDate(month: String, year: String): String { - val monthFormatted = month.padStart(DATE_LENGTH, '0') - val yearFormatted = year.takeLast(DATE_LENGTH) - return "$monthFormatted/$yearFormatted" - } -} \ 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/TangemPayDetailIntents.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt new file mode 100644 index 0000000000..dbbe82010d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt @@ -0,0 +1,12 @@ +package com.tangem.features.tangempay.utils + +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState + +internal interface TangemPayDetailIntents { + fun onRefreshSwipe(refreshState: ShowRefreshState) + fun onClickAddFunds() + fun onClickChangePin() + fun onClickFreezeCard() + fun onClickUnfreezeCard() + fun onClickTermsAndLimits() +} \ 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 deleted file mode 100644 index 2f5ce481ea..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayErrorMessageFactory.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.tangempay.utils - -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.features.tangempay.entity.TangemPayDetailsErrorType - -internal object TangemPayErrorMessageFactory { - - fun createError(type: TangemPayDetailsErrorType, onDismiss: () -> Unit): MessageBottomSheetUMV2 { - return when (type) { - TangemPayDetailsErrorType.Receive -> messageBottomSheetUM { - infoBlock { - icon(R.drawable.img_attention_20) { - backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention - } - title = TextReference.Res(R.string.tangempay_card_details_receive_error_title) - body = TextReference.Res(R.string.tangempay_card_details_receive_error_description) - } - primaryButton { - text = resourceReference(R.string.common_got_it) - onClick { onDismiss() } - } - } - } - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt new file mode 100644 index 0000000000..94b52cde6c --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -0,0 +1,70 @@ +package com.tangem.features.tangempay.utils + +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 TangemPayMessagesFactory { + + fun createErrorMessage(type: TangemPayDetailsErrorType): BottomSheetMessageV2 { + return when (type) { + TangemPayDetailsErrorType.Receive -> bottomSheetMessage { + infoBlock { + icon(R.drawable.img_attention_20) { + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention + } + title = TextReference.Res(R.string.tangempay_card_details_receive_error_title) + body = TextReference.Res(R.string.tangempay_card_details_receive_error_description) + } + primaryButton { + text = resourceReference(R.string.common_got_it) + onClick { closeBs() } + } + } + } + } + + fun createFreezeCardMessage(onFreezeClicked: () -> Unit): BottomSheetMessageV2 { + return bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_snow_24) { + type = MessageBottomSheetUMV2.Icon.Type.Accent + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Accent + } + title = TextReference.Res(R.string.tangem_pay_freeze_card_alert_title) + body = TextReference.Res(R.string.tangem_pay_freeze_card_alert_body) + } + primaryButton { + text = resourceReference(R.string.tangem_pay_freeze_card_freeze) + onClick { + onFreezeClicked() + closeBs() + } + } + } + } + + fun createUnfreezeCardMessage(onUnfreezeClicked: () -> Unit): BottomSheetMessageV2 { + return bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_snow_24) { + type = MessageBottomSheetUMV2.Icon.Type.Accent + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Accent + } + title = TextReference.Res(R.string.tangem_pay_unfreeze_card_alert_title) + body = TextReference.Res(R.string.tangem_pay_unfreeze_card_alert_body) + } + primaryButton { + text = resourceReference(R.string.tangempay_card_details_unfreeze_card) + onClick { + onUnfreezeClicked() + closeBs() + } + } + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt index 40cd31c7da..f1b971367c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt @@ -1,5 +1,6 @@ package com.tangem.features.tangempay.utils +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository @@ -36,8 +37,9 @@ internal class TangemPayTxHistoryListManager( val emptyStatus: Flow = state.map { it.isEmpty }.distinctUntilChanged() val paginationStatus: Flow> = state.map { it.status }.distinctUntilChanged() - suspend fun launchPagination() = coroutineScope { + suspend fun launchPagination(userWalletId: UserWalletId) = coroutineScope { val batchFlow = repository.getTxHistoryBatchFlow( + userWalletId = userWalletId, context = TangemPayTxHistoryListBatchingContext(actionsFlow = actionsFlow, coroutineScope = this), batchSize = 50, ) 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/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUpdateListener.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUpdateListener.kt new file mode 100644 index 0000000000..54db0c92b2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUpdateListener.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay.utils + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class TangemPayTxHistoryUpdateListener @Inject constructor() { + + private val updateChannel = Channel(Channel.BUFFERED) + val updates: Flow = updateChannel.receiveAsFlow() + + suspend fun triggerUpdate() { + updateChannel.send(Unit) + } +} \ No newline at end of file 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..a35a3e4657 100644 --- a/features/tangempay/onboarding/impl/build.gradle.kts +++ b/features/tangempay/onboarding/impl/build.gradle.kts @@ -16,6 +16,8 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.core.error) + implementation(projects.core.navigation) + implementation(deps.arrow.core) implementation(deps.arrow.core) /** Common */ @@ -30,6 +32,9 @@ dependencies { /** Domain */ implementation(projects.domain.visa) + /** Data **/ + implementation(projects.data.visa) + /** Compose */ implementation(deps.compose.foundation) implementation(deps.compose.material3) @@ -39,4 +44,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/detekt-baseline-debug.xml b/features/tangempay/onboarding/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..244ca0e5f5 --- /dev/null +++ b/features/tangempay/onboarding/impl/detekt-baseline-debug.xml @@ -0,0 +1,9 @@ + + + + + MultilineLambdaItParameter:TangemPayOnboardingModel.kt$TangemPayOnboardingModel${ TangemPayOnboardingScreenState.Content( onBack = it.onBack, onTermsClick = ::onTermsClick, buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig( isLoading = false, onClick = ::onGetCardClick, ), ) } + MultilineLambdaItParameter:TangemPayOnboardingModel.kt$TangemPayOnboardingModel${ Timber.e("Error getCustomerInfo: ${it.errorCode}") uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) } + NullableToStringCall:TangemPayOnboardingModel.kt$TangemPayOnboardingModel$${result.leftOrNull()?.message} + + 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..688336b8ca 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 @@ -6,28 +6,39 @@ 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.data.pay.util.TangemPayWalletsManager import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase +import com.tangem.features.tangempay.TangemPayConstants import com.tangem.features.tangempay.components.TangemPayOnboardingComponent +import com.tangem.features.tangempay.model.transformers.TangemPayOnboardingButtonLoadingTransformer 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 +import com.tangem.utils.transformer.update as transformerUpdate @Stable @ModelScoped +@Suppress("LongParameterList") internal class TangemPayOnboardingModel @Inject constructor( paramsContainer: ParamsContainer, private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val repository: OnboardingRepository, + private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase, + private val urlOpener: UrlOpener, + private val tangemPayWalletsManager: TangemPayWalletsManager, ) : Model() { private val params = paramsContainer.require() - - val screenState: StateFlow - field = MutableStateFlow(TangemPayOnboardingScreenState()) + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) init { modelScope.launch { @@ -37,29 +48,37 @@ 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 { + TangemPayOnboardingScreenState.Content( + onBack = it.onBack, + onTermsClick = ::onTermsClick, + buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig( + isLoading = false, + onClick = ::onGetCardClick, + ), + ) + } } private suspend fun checkCustomerInfo() { - repository.getCustomerInfo() + // TODO implement selector + repository.getCustomerInfo( + userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking().walletId, + ) + // selector .onRight { customerInfo -> when { !customerInfo.isKycApproved -> { when (params) { - is TangemPayOnboardingComponent.Params.Deeplink -> - screenState.value = screenState.value.copy(fullScreenLoading = false) + is TangemPayOnboardingComponent.Params.Deeplink -> showOnboarding() else -> openKyc() } } @@ -68,4 +87,57 @@ internal class TangemPayOnboardingModel @Inject constructor( } .onLeft { back() } } + + private fun onTermsClick() { + urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) + } + + private fun onGetCardClick() { + uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = true)) + modelScope.launch { + // TODO implement selector + val userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking().walletId + val result = produceInitialDataUseCase(userWalletId) + if (result.isLeft()) { + Timber.e("Error producing initial data: ${result.leftOrNull()?.message}") + uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) + return@launch + } + + // TODO implement selector + repository.getCustomerInfo( + userWalletId = userWalletId, + ).fold( + ifLeft = { + Timber.e("Error getCustomerInfo: ${it.errorCode}") + uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) + }, + ifRight = { customerInfo -> + if (customerInfo.isKycApproved) { + back() + } else { + openKyc() + } + }, + ) + } + } + + private fun openKyc() { + // TODO implement selector + router.replaceAll( + AppRoute.Wallet, + AppRoute.Kyc( + userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking().walletId, + ), + ) + } + + private fun back() { + router.pop() + } + + private fun getInitialState(): TangemPayOnboardingScreenState { + return TangemPayOnboardingScreenState.Loading(onBack = ::back) + } } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayOnboardingButtonLoadingTransformer.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayOnboardingButtonLoadingTransformer.kt new file mode 100644 index 0000000000..0b01a766a2 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayOnboardingButtonLoadingTransformer.kt @@ -0,0 +1,14 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState +import com.tangem.utils.transformer.Transformer + +internal class TangemPayOnboardingButtonLoadingTransformer( + private val isLoading: Boolean, +) : Transformer { + override fun transform(prevState: TangemPayOnboardingScreenState): TangemPayOnboardingScreenState { + val contentState = prevState as? TangemPayOnboardingScreenState.Content ?: return prevState + val updatedButtonConfig = contentState.buttonConfig.copy(isLoading = isLoading) + return contentState.copy(buttonConfig = updatedButtonConfig) + } +} \ 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..af5e9fcae7 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 @@ -1,53 +1,241 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +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.CircularProgressIndicator +import androidx.compose.material3.Icon 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.graphics.Brush +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.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton 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.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.TangemTheme 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.onBack, iconRes = R.drawable.ic_back_24, ) }, contentWindowInsets = WindowInsetsZero, content = { paddingValues -> - TangemPayOnboardingContent( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize(), - state = state, - onButtonClick = onOpenKycClick, - ) + val contentModifier = Modifier + .systemBarsPadding() + .padding(paddingValues) + .fillMaxSize() + when (state) { + is TangemPayOnboardingScreenState.Loading -> TangemPayOnboardingLoading(modifier = contentModifier) + is TangemPayOnboardingScreenState.Content -> TangemPayOnboardingContent( + state = state, + modifier = contentModifier, + ) + } }, ) } +@Composable +private fun TangemPayOnboardingLoading(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier, + color = TangemTheme.colors.icon.primary1, + ) + } +} + +@Composable +private fun TangemPayOnboardingContent(state: TangemPayOnboardingScreenState.Content, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier + .padding(bottom = 16.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(id = R.drawable.img_tangem_pay_visa), + contentDescription = null, + modifier = Modifier + .size(width = 204.dp, height = 130.dp) + .clip(RoundedCornerShape(16.dp)) + .border( + width = 1.dp, + brush = Brush.linearGradient( + colors = listOf( + TangemTheme.colors.text.constantWhite.copy(alpha = 0.1F), + TangemTheme.colors.text.constantWhite.copy(alpha = 0f), + ), + ), + shape = RoundedCornerShape(16.dp), + ), + ) + + Text( + text = stringResourceSafe(R.string.tangempay_onboarding_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 24.dp) + .padding(horizontal = 12.dp) + .fillMaxWidth(), + ) + + TangemPayOnboardingBlocks( + modifier = Modifier + .padding(top = 24.dp) + .padding(horizontal = 12.dp), + ) + } + FooterButtons( + modifier = Modifier.padding(bottom = 16.dp), + primaryButtonConfig = state.buttonConfig, + onTermsClick = state.onTermsClick, + ) + } +} + +@Composable +internal fun TangemPayOnboardingBlocks(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + TangemPayOnboardingBlock( + painterRes = R.drawable.ic_security_check_24, + titleRef = TextReference.Res(R.string.tangempay_onboarding_security_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_security_description), + ) + + TangemPayOnboardingBlock( + painterRes = R.drawable.ic_shopping_basket_24, + titleRef = TextReference.Res(R.string.tangempay_onboarding_purchases_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_purchases_description), + ) + + TangemPayOnboardingBlock( + painterRes = R.drawable.ic_credit_card_add_24, + titleRef = TextReference.Res(R.string.tangempay_onboarding_pay_title), + descriptionRef = TextReference.Res(R.string.tangempay_onboarding_pay_description), + ) + } +} + +@Composable +private fun TangemPayOnboardingBlock( + @DrawableRes painterRes: Int, + titleRef: TextReference, + descriptionRef: TextReference, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + Icon( + painter = painterResource(id = painterRes), + contentDescription = null, + modifier = Modifier.size(width = 24.dp, height = 24.dp), + tint = TangemTheme.colors.icon.accent, + ) + Column( + modifier = Modifier + .padding(start = 12.dp) + .fillMaxWidth(), + ) { + Text( + text = titleRef.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = descriptionRef.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Composable +private fun FooterButtons( + primaryButtonConfig: TangemPayOnboardingScreenState.Content.ButtonConfig, + onTermsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits), + onClick = onTermsClick, + ) + NavigationPrimaryButton( + primaryButton = NavigationButton( + textReference = resourceReference(R.string.tangempay_onboarding_get_card_button_text), + iconRes = R.drawable.ic_tangem_24, + isIconVisible = true, + shouldShowProgress = primaryButtonConfig.isLoading, + onClick = primaryButtonConfig.onClick, + ), + ) + } +} + @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewDarkTheme() { +private fun TandemPayOnboardingScreenPreview( + @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.Loading(onBack = {}), + TangemPayOnboardingScreenState.Content( + onBack = {}, + onTermsClick = {}, + buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig(isLoading = false, onClick = {}), + ), + TangemPayOnboardingScreenState.Content( + onBack = {}, + onTermsClick = {}, + buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig(isLoading = true, onClick = {}), + ), + ), + ) \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt deleted file mode 100644 index 94dc71b1ba..0000000000 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlock.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tangem.features.tangempay.ui - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.* -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.unit.dp -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemTheme - -@Composable -internal fun TangemPayOnboardingBlock( - @DrawableRes painterRes: Int, - titleRef: TextReference, - descriptionRef: TextReference, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .padding(start = 32.dp, end = 32.dp), - ) { - Icon( - painter = painterResource(id = painterRes), - contentDescription = null, - modifier = Modifier - .width(24.dp) - .height(24.dp), - ) - Column( - modifier = Modifier.padding(start = 12.dp), - ) { - Text( - text = titleRef.resolveReference(), - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - ) - Text( - text = descriptionRef.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } -} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt deleted file mode 100644 index 71ae0aadcc..0000000000 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingBlocks.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.features.tangempay.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.extensions.TextReference - -@Composable -internal fun TangemPayOnboardingBLocks(modifier: Modifier = Modifier) { - TangemPayOnboardingBlock( - modifier = modifier, - painterRes = R.drawable.ic_security_check_22, - titleRef = TextReference.Res(R.string.tangempay_onboarding_security_title), - descriptionRef = TextReference.Res(R.string.tangempay_onboarding_security_description), - ) - - SpacerH(18.dp) - - TangemPayOnboardingBlock( - modifier = modifier, - painterRes = R.drawable.ic_shopping_basket_22, - titleRef = TextReference.Res(R.string.tangempay_onboarding_purchases_title), - descriptionRef = TextReference.Res(R.string.tangempay_onboarding_purchases_description), - ) - - SpacerH(18.dp) - - TangemPayOnboardingBlock( - modifier = modifier, - painterRes = R.drawable.ic_credit_card_add_22, - titleRef = TextReference.Res(R.string.tangempay_onboarding_pay_title), - descriptionRef = TextReference.Res(R.string.tangempay_onboarding_pay_description), - ) -} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt deleted file mode 100644 index 25b03d0e92..0000000000 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingContent.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.tangem.features.tangempay.ui - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.material3.CircularProgressIndicator -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.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.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.tangempay.onboarding.impl.R - -@Composable -internal fun TangemPayOnboardingContent( - state: TangemPayOnboardingScreenState, - onButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { - if (state.fullScreenLoading) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier, - color = TangemTheme.colors.icon.primary1, - ) - } - } else { - Column( - modifier - .fillMaxSize() - .navigationBarsPadding(), - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(top = 24.dp, bottom = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(id = R.drawable.img_tangem_pay_onboarding), - contentDescription = null, - modifier = Modifier - .height(250.dp) - .fillMaxWidth(), - ) - - Text( - text = stringResourceSafe(R.string.tangempay_onboarding_title), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - ) - - SpacerH(32.dp) - - TangemPayOnboardingBLocks() - } - - NavigationPrimaryButton( - modifier = Modifier - .imePadding() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .fillMaxWidth(), - primaryButton = NavigationButton( - textReference = resourceReference(R.string.tangempay_onboarding_get_card_button_text), - iconRes = R.drawable.ic_tangem_24, - isIconVisible = true, - shouldShowProgress = state.buttonLoading, - onClick = onButtonClick, - ), - ) - } - } -} \ 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..af92226019 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 @@ -3,7 +3,15 @@ package com.tangem.features.tangempay.ui import javax.annotation.concurrent.Immutable @Immutable -internal data class TangemPayOnboardingScreenState( - val fullScreenLoading: Boolean = true, - val buttonLoading: Boolean = false, -) \ No newline at end of file +internal sealed class TangemPayOnboardingScreenState { + abstract val onBack: () -> Unit + + data class Loading(override val onBack: () -> Unit) : TangemPayOnboardingScreenState() + data class Content( + override val onBack: () -> Unit, + val onTermsClick: () -> Unit, + val buttonConfig: ButtonConfig, + ) : TangemPayOnboardingScreenState() { + data class ButtonConfig(val isLoading: Boolean, val onClick: () -> Unit) + } +} \ No newline at end of file diff --git a/features/tester/impl/detekt-baseline-debug.xml b/features/tester/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..4a5ba6a9ce --- /dev/null +++ b/features/tester/impl/detekt-baseline-debug.xml @@ -0,0 +1,37 @@ + + + + + BooleanPropertyNaming:ExcludedBlockchainsScreenUM.kt$ExcludedBlockchainsScreenUM$val showRecoverWarning: Boolean + CanBeNonNullable:TestPushDeepLinkBottomSheet.kt$config: TangemBottomSheetConfig? + CastNullableToNonNullableType:TestPushMarketTokenSearchBottomSheetTransformer.kt$TestPushMarketTokenSearchBottomSheetTransformer$as + CastNullableToNonNullableType:TestPushViewModel.kt$TestPushViewModel$as + ExplicitCollectionElementAccessMethod:TestPushAddKeyDataTransformer.kt$TestPushAddKeyDataTransformer$mutableData.set(index = index, updated) + ExplicitCollectionElementAccessMethod:TestPushAddValueDataTransformer.kt$TestPushAddValueDataTransformer$mutableData.set(index = index, updated) + MaxChainedCallsOnSameLine:BlockchainProvidersScreen.kt$ProvidersDnDTarget$event.toAndroidDragEvent().clipData.getItemAt(0).text.toString().toInt() + MultilineLambdaItParameter:ApiEnvironmentComparator.kt$ApiEnvironmentComparator${ when (it) { ApiEnvironment.DEV -> 0 ApiEnvironment.DEV_2 -> 1 ApiEnvironment.DEV_3 -> 2 ApiEnvironment.STAGE -> 3 ApiEnvironment.MOCK -> 4 ApiEnvironment.PROD -> 5 } } + MultilineLambdaItParameter:BlockchainProvidersScreen.kt${ value = it state.onValueChange(it.text) } + MultilineLambdaItParameter:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel${ if (it.blockchainId == blockchainId) { it.update() } else { it } } + MultilineLambdaItParameter:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel${ it.copyProvidersUM(blockchainId = id) { copy( addPublicProviderDialog = addPublicProviderDialog.copy( hasError = !PatternsCompat.WEB_URL.matcher(url).matches(), ), ) } } + MultilineLambdaItParameter:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel${ it.copyProvidersUM(blockchainId = id) { copy(providers = providers.toImmutableList()) } } + MultilineLambdaItParameter:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel${ it.key.fullName.contains(other = query, ignoreCase = true) || it.key.currency.contains(other = query, ignoreCase = true) } + MultilineLambdaItParameter:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel${ val (blockchain, providers) = it ProvidersUM( blockchainId = blockchain.id, blockchainName = blockchain.fullName, blockchainSymbol = blockchain.currency, providers = providers.map(BlockchainProvidersUM::ProviderUM).toImmutableList(), isExpanded = false, onDrop = { prev, current -> onDrop(blockchain.id, prev, current) }, addPublicProviderDialog = AddPublicProviderDialogUM( hasError = false, onValueChange = { onPublicProviderUrlChange(id = blockchain.id, url = it) }, onSaveClick = { onAddProviderClick(id = blockchain.id, url = it) }, ), ) } + MultilineLambdaItParameter:EnvironmentTogglesScreen.kt${ Text( text = it, color = TangemTheme.colors.text.accent, overflow = TextOverflow.Ellipsis, maxLines = 1, style = TangemTheme.typography.body2, ) } + MultilineLambdaItParameter:EnvironmentsTogglesViewModel.kt$EnvironmentsTogglesViewModel${ val (config, currentEnvironment) = it if (config.environmentConfigs.size <= 1) return@mapNotNull null EnvironmentTogglesScreenUM.ApiInfoUM( name = config.id.name, select = currentEnvironment.name, url = config.environmentConfigs.firstOrNull { it.environment == currentEnvironment }?.baseUrl ?: error("Current environment's url isn't found"), environments = config.environmentConfigs .sortedWith(ApiEnvironmentComparator) .map { environmentConfig -> environmentConfig.environment.name } .toImmutableSet(), ) } + MultilineLambdaItParameter:TestPushViewModel.kt$TestPushViewModel${ _uiState.update( TestPushMarketTokenClickBottomSheetTransformer( it, ), ) marketsJobHolder.cancel() dismissBottomSheet() } + MultilineLambdaItParameter:TestPushViewModel.kt$TestPushViewModel${ _uiState.update( TestPushMarketTokenSearchBottomSheetTransformer( it, ), ) onMarketsBatchReload(it.text) } + MultilineLambdaItParameter:TesterAccountsViewModel.kt$TesterAccountsViewModel${ it.copy( accountListBottomSheetConfig = it.accountListBottomSheetConfig.copy( isAccountsShown = false, ), ) } + MultilineLambdaItParameter:TesterAccountsViewModel.kt$TesterAccountsViewModel${ it.copy( walletSelector = it.walletSelector.copy(selected = userWallet), accountListBottomSheetConfig = it.accountListBottomSheetConfig.copy( accounts = getWalletAccounts(userWallet.walletId), ), ) } + MultilineLambdaItParameter:TesterActionsScreen.kt${ builder.setStream( FileProvider.getUriForFile(activity, "${activity.packageName}.provider", it), ) } + MultilineLambdaItParameter:TesterActivity.kt$TesterActivity${ val route = when (it) { ButtonUM.FEATURE_TOGGLES -> TesterScreen.FEATURE_TOGGLES ButtonUM.EXCLUDED_BLOCKCHAINS -> TesterScreen.EXCLUDED_BLOCKCHAINS ButtonUM.ENVIRONMENT_TOGGLES -> TesterScreen.ENVIRONMENTS_TOGGLES ButtonUM.BLOCKCHAIN_PROVIDERS -> TesterScreen.BLOCKCHAIN_PROVIDERS ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS } innerTesterRouter.open(route) } + NoNameShadowing:AccountsScreen.kt$content + NoNameShadowing:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel${ onAddProviderClick(id = blockchain.id, url = it) } + NoNameShadowing:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel${ onPublicProviderUrlChange(id = blockchain.id, url = it) } + NoNameShadowing:EnvironmentsTogglesViewModel.kt$EnvironmentsTogglesViewModel${ it.environment == currentEnvironment } + NoNameShadowing:TesterActivity.kt$TesterActivity${ val route = when (it) { ButtonUM.FEATURE_TOGGLES -> TesterScreen.FEATURE_TOGGLES ButtonUM.EXCLUDED_BLOCKCHAINS -> TesterScreen.EXCLUDED_BLOCKCHAINS ButtonUM.ENVIRONMENT_TOGGLES -> TesterScreen.ENVIRONMENTS_TOGGLES ButtonUM.BLOCKCHAIN_PROVIDERS -> TesterScreen.BLOCKCHAIN_PROVIDERS ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS } innerTesterRouter.open(route) } + PropertyUsedBeforeDeclaration:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel$_state + PropertyUsedBeforeDeclaration:BlockchainProvidersViewModel.kt$BlockchainProvidersViewModel$searchQuery + PropertyUsedBeforeDeclaration:EnvironmentsTogglesViewModel.kt$EnvironmentsTogglesViewModel$_uiState + PropertyUsedBeforeDeclaration:TesterActionsViewModel.kt$TesterActionsViewModel$initialState + + 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