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..f1d548fd8e --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -0,0 +1,149 @@ +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' is displayed") { + onSendAddressScreen { + recentAddressItem(address).assertIsDisplayed() + } + } + step("Assert recent address item description is '$description'") { + onSendAddressScreen { + recentAddressItemDescription(description).assertTextContains( + description, + substring = true + ) + } + } +} \ 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..7ed6cddb65 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -3,24 +3,162 @@ 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): 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 (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 recentAddressItemDescription(recipientAddress: String): KNode = recentAddressItem(recipientAddress).child { + hasParent(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT)) + useUnmergedTree = true + } + + fun destinationTagBlockTitle(isMemoCorrectOrEmpty: Boolean = true): KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD_TITLE) + useUnmergedTree = true + if(isMemoCorrectOrEmpty) { + hasText(getResourceString(CoreUiR.string.send_destination_tag_field)) + } else { + hasText(getResourceString(CoreUiR.string.send_memo_destination_tag_error)) + } + } + + val destinationTagTextField: KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD) + useUnmergedTree = true + } + + val 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..8beeddb2eb 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -2,36 +2,55 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.core.ui.R -import com.tangem.core.ui.test.BaseButtonTestTags -import com.tangem.core.ui.test.NotificationTestTags -import com.tangem.core.ui.test.SendConfirmScreenTestTags -import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.test.* +import com.tangem.wallet.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasText as withText -import com.tangem.common.ui.R as CommonUiR +import androidx.compose.ui.test.hasTestTag as withTestTag +import com.tangem.core.ui.R as CoreUiR class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { - val title: KNode = child { + val appBarTitle: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) useUnmergedTree = true } val sendButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) - hasText(getResourceString(R.string.common_send)) + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.common_send))) useUnmergedTree = true } + val primaryAmount: KNode = child { + hasTestTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT) + useUnmergedTree = true + } - val minimumSendAmountErrorTitle: KNode = child { + val secondaryAmount: KNode = child { + hasTestTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT) + useUnmergedTree = true + } + + fun leaveDepositButton(amount: String): KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.send_notification_leave_button, amount))) + useUnmergedTree = true + } + + fun reduceAmountButton(amount: String): KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.send_notification_reduce_by, amount))) + useUnmergedTree = true + } + + fun warningTitle(title: String): KNode = child { hasTestTag(NotificationTestTags.TITLE) - hasText(getResourceString(CommonUiR.string.send_notification_invalid_amount_title)) + hasText(title) useUnmergedTree = true } @@ -40,34 +59,76 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } - fun minimumSendAmountErrorIcon(amount: String): KNode = child { - hasAnySibling( - withText( - getResourceString( - CommonUiR.string.send_notification_invalid_minimum_amount_text, - amount, - amount, - ) - ) - ) + fun sendWarningIcon(message: String): KNode = child { hasTestTag(NotificationTestTags.ICON) + hasAnySibling(withText(message)) useUnmergedTree = true } - fun minimumSendAmountErrorMessage( - amount: String, + fun sendWarningMessage( + message: String, ): KNode = child { hasTestTag(NotificationTestTags.MESSAGE) - hasText( - getResourceString( - CommonUiR.string.send_notification_invalid_minimum_amount_text, - amount, - amount, - ) - ) + hasText(message) useUnmergedTree = true } + fun warningMessage(messageResId: Int): KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(getResourceString(messageResId)) + useUnmergedTree = true + } + + fun warningIcon(message: String): KNode = child { + hasTestTag(NotificationTestTags.ICON) + hasAnySibling(withText(message)) + useUnmergedTree = true + } + + fun recipientAddress(recipientAddress: String): KNode = child { + hasTestTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS) + hasText(recipientAddress) + useUnmergedTree = true + } + + val blockchainAddress: KNode = child { + hasTestTag(SendConfirmScreenTestTags.BLOCKCHAIN_ADDRESS) + useUnmergedTree = true + } + + val feeSelectorBlock: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK) + } + + val feeSelectorIcon: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.ICON) + useUnmergedTree = true + } + + val feeSelectorTitle: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.TITLE) + useUnmergedTree = true + } + + val feeSelectorTooltipIcon: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.TOOLTIP_ICON) + useUnmergedTree = true + } + + val selectFeeIcon: KNode = child { + hasTestTag(FeeSelectorBlockTestTags.SELECT_FEE_ICON) + useUnmergedTree = true + } + + val feeAmount: KNode = child { + hasParent(withTestTag(FeeSelectorBlockTestTags.FEE_AMOUNT)) + useUnmergedTree = true + } + + val refreshButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(CoreUiR.string.warning_button_refresh)) + } } internal fun BaseTestCase.onSendConfirmScreen(function: SendConfirmPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt index d12952956b..1e1f1da9d2 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -38,6 +38,16 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } + val equivalentInputAmount: KNode = child { + hasTestTag(SendScreenTestTags.EQUIVALENT_INPUT_AMOUNT) + useUnmergedTree = true + } + + val exchangeIcon: KNode = child { + hasTestTag(SendScreenTestTags.EXCHANGE_ICON) + useUnmergedTree = true + } + val tokenName: KNode = child { hasTestTag(SendScreenTestTags.TOKEN_NAME) useUnmergedTree = true @@ -64,6 +74,12 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } + val continueButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(SendR.string.common_continue)) + useUnmergedTree = true + } + } internal fun BaseTestCase.onSendScreen(function: SendPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapIsNotSupportedDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapIsNotSupportedDialogPageObject.kt new file mode 100644 index 0000000000..2f897e61fd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapIsNotSupportedDialogPageObject.kt @@ -0,0 +1,35 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.BaseDialogTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.common.ui.R as CommonUIR + +class SwapIsNotSupportedDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + fun text(currencyName: String): KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + hasText( + getResourceString( + CommonUIR.string.token_button_unavailability_reason_not_exchangeable, + currencyName + ) + ) + useUnmergedTree = true + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } +} + +internal fun BaseTestCase.onSwapIsNotSupportedDialog(function: SwapIsNotSupportedDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 1c3fa1fbb1..dff98cfcee 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -97,25 +97,31 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide ) @OptIn(ExperimentalTestApi::class) - val swapButton: LazyListItemNode = horizontalActionChips.childWith { + fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith { + hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) + hasText(getResourceString(R.string.common_receive)) + } + + @OptIn(ExperimentalTestApi::class) + fun swapButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_swap)) } @OptIn(ExperimentalTestApi::class) - val sellButton: LazyListItemNode = horizontalActionChips.childWith { + fun sellButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_sell)) } @OptIn(ExperimentalTestApi::class) - val buyButton: LazyListItemNode = horizontalActionChips.childWith { + fun buyButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_buy)) } @OptIn(ExperimentalTestApi::class) - val sendButton: LazyListItemNode = horizontalActionChips.childWith { + fun sendButton(): LazyListItemNode = horizontalActionChips.childWith { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasText(getResourceString(R.string.common_send)) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt index 28b46d345c..df177bb300 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt @@ -2,14 +2,23 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.CARDANO_ADDRESS +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.pullToRefresh +import com.tangem.common.ui.R import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.checkSendWarning import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openSendScreen import com.tangem.scenarios.synchronizeAddresses -import com.tangem.screens.* +import com.tangem.screens.onMainScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.screens.onTokenDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test @@ -26,28 +35,18 @@ class BlockchainTest : BaseTestCase() { val validSendAmount = "10" val minAmount = "ADA 1.00" val address = CARDANO_ADDRESS - val scenarioName = "user_tokens_api" - val scenarioState = "Cardano" + + val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title) + val invalidAmountMessage = + getResourceString(R.string.send_notification_invalid_minimum_amount_text, minAmount, minAmount) setupHooks( additionalAfterSection = { - resetWireMockScenarioState(scenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { - step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { - setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) - } - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Click on token with name: '$tokenName'") { - onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } - } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton.performClick() } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) } step("Type '$errorSendAmount' in input text field") { onSendScreen { @@ -64,47 +63,30 @@ class BlockchainTest : BaseTestCase() { step("Click on 'Next' button") { onSendAddressScreen { nextButton.clickWithAssertion() } } - step("Assert 'Invalid amount' error title is displayed") { - onSendConfirmScreen { minimumSendAmountErrorTitle.assertIsDisplayed() } + step("Assert 'Invalid amount warning' is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + ) } - step("Assert 'Invalid amount' error icon is displayed") { - onSendConfirmScreen { minimumSendAmountErrorIcon(minAmount).assertIsDisplayed() } - } - step("Assert 'Invalid amount' error message is displayed") { - onSendConfirmScreen { minimumSendAmountErrorMessage(minAmount).assertIsDisplayed() } - } - step("Press system 'Back' button") { - device.uiDevice.pressBack() - } - step("Assert address text field is displayed") { - onSendAddressScreen { addressTextField.assertIsDisplayed() } - } - step("Press system 'Back' button") { - device.uiDevice.pressBack() + step("Click on 'Amount' field") { + onSendConfirmScreen { primaryAmount.clickWithAssertion() } } step("Type '$validSendAmount' in input text field") { onSendScreen { amountInputTextField.performClick() - amountInputTextField.performTextReplacement(errorSendAmount) + amountInputTextField.performTextReplacement(validSendAmount) } } - step("Click on 'Next' button") { - onSendScreen { nextButton.clickWithAssertion() } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } } - step("Assert address text field is displayed") { - onSendAddressScreen { addressTextField.assertIsDisplayed() } - } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } - } - step("Assert 'Invalid amount' error title is not displayed") { - onSendConfirmScreen { minimumSendAmountErrorTitle.assertIsNotDisplayed() } - } - step("Assert 'Invalid amount' error icon is not displayed") { - onSendConfirmScreen { minimumSendAmountErrorIcon(minAmount).assertIsNotDisplayed() } - } - step("Assert 'Invalid amount' error message is not displayed") { - onSendConfirmScreen { minimumSendAmountErrorMessage(minAmount).assertIsNotDisplayed() } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) } } } @@ -136,10 +118,16 @@ class BlockchainTest : BaseTestCase() { setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) } step("Set WireMock scenario: '$rippleAccountInfoScenarioName' to state: '$rippleAccountInfoErrorState'") { - setWireMockScenarioState(scenarioName = rippleAccountInfoScenarioName, state = rippleAccountInfoErrorState) + setWireMockScenarioState( + scenarioName = rippleAccountInfoScenarioName, + state = rippleAccountInfoErrorState + ) } step("Set WireMock scenario: '$rippleAccountLinesScenarioName' to state: '$rippleAccountLinesErrorState'") { - setWireMockScenarioState(scenarioName = rippleAccountLinesScenarioName, state = rippleAccountLinesErrorState) + setWireMockScenarioState( + scenarioName = rippleAccountLinesScenarioName, + state = rippleAccountLinesErrorState + ) } step("Open 'Main Screen'") { openMainScreen() @@ -169,10 +157,16 @@ class BlockchainTest : BaseTestCase() { setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) } step("Set WireMock scenario: '$rippleAccountInfoScenarioName' to state: '$rippleAccountInfoStartedState'") { - setWireMockScenarioState(scenarioName = rippleAccountInfoScenarioName, state = rippleAccountInfoStartedState) + setWireMockScenarioState( + scenarioName = rippleAccountInfoScenarioName, + state = rippleAccountInfoStartedState + ) } step("Set WireMock scenario: '$rippleAccountLinesScenarioName' to state: '$rippleAccountLinesStartedState'") { - setWireMockScenarioState(scenarioName = rippleAccountLinesScenarioName, state = rippleAccountLinesStartedState) + setWireMockScenarioState( + scenarioName = rippleAccountLinesScenarioName, + state = rippleAccountLinesStartedState + ) } step("Pull to refresh") { pullToRefresh() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/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..f6cd0fc1e3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -15,7 +15,6 @@ import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.sdk.mocks.MockProvider import com.tangem.tap.store import dagger.hilt.android.testing.HiltAndroidTest -import io.qameta.allure.kotlin.Allure import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test @@ -73,7 +72,7 @@ class FeedbackTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } step("Click 'Send' button") { - onTokenDetailsScreen { sendButton.performClick() } + onTokenDetailsScreen { sendButton().performClick() } } step("Type '$sendAmount' in input text field") { onSendScreen { @@ -130,7 +129,7 @@ class FeedbackTest : BaseTestCase() { MockProvider.resetEmulateError() } ).run { - Allure.step("Click on 'Accept' button") { + step("Click on 'Accept' button") { onDisclaimerScreen { acceptButton.clickWithAssertion() } } step("Set scanning error") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt index 014b42a242..d237730598 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt @@ -47,7 +47,7 @@ class SwapTokenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton.performClick() } + onTokenDetailsScreen { swapButton().performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -131,7 +131,7 @@ class SwapTokenTest : BaseTestCase() { disableMobileData() } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton.performClick() } + onTokenDetailsScreen { swapButton().performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -175,7 +175,7 @@ class SwapTokenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton.performClick() } + onTokenDetailsScreen { swapButton().performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/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..0f72322a4e 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 @@ -121,7 +126,6 @@ class MainScreenActionButtonsTest : BaseTestCase() { @Test fun clickOnBuyButtonTest() { val tokenTitle = "Bitcoin" - val tokenSymbol = "BTC" setupHooks().run { step("Open 'Main Screen'") { @@ -160,12 +164,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 +279,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 +317,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 +366,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..13d2d3d727 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/MyWalletsBlockTest.kt @@ -0,0 +1,137 @@ +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 recent address item is not displayed") { + onSendAddressScreen { recentAddressItem(ETHEREUM_ADDRESS).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..74e5dac269 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt @@ -0,0 +1,330 @@ +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 descriptionForWalletTransaction = "DOGE 0.0192, 11/10/2025, 1:46 PM" + + @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("Press system 'Back' button") { + device.uiDevice.pressBack() + } + 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 = "POL (ex-MATIC)" + val sendAmount = "1" + + setupHooks().run { + 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" + val descriptionForTransaction = "DOGE 0.10, 11/10/2025, 1:46 PM" + + 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 = descriptionForWalletTransaction) + } + step("Check recent address item №2") { + checkRecentAddressItem(address = shortenedRecipientAddress, description = descriptionForTransaction) + } + step("Check recent address item №3") { + checkRecentAddressItem(address = recipientAddressBase + "k", description = descriptionForTransaction) + } + step("Check recent address item №4") { + checkRecentAddressItem(address = recipientAddressBase + "c", description = descriptionForTransaction) + } + step("Check recent address item №5") { + checkRecentAddressItem(address = recipientAddressBase + "d", description = descriptionForTransaction) + } + step("Check recent address item №6") { + checkRecentAddressItem(address = recipientAddressBase + "e", description = descriptionForTransaction) + } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } + step("Check recent address item №7") { + checkRecentAddressItem(address = recipientAddressBase + "f", description = descriptionForTransaction) + } + step("Check recent address item №8") { + checkRecentAddressItem(address = recipientAddressBase + "g", description = descriptionForTransaction) + } + step("Check recent address item №9") { + checkRecentAddressItem(address = recipientAddressBase + "o", description = descriptionForTransaction) + } + step("Check recent address item №10") { + checkRecentAddressItem(address = recipientAddressBase + "p", description = descriptionForTransaction) + } + 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" + val descriptionForWalletTransaction2 = "DOGE 0.0192, 11/09/2025, 3:18 PM" + + 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") { + checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = descriptionForWalletTransaction) + } + step("Check recent address item") { + checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = descriptionForWalletTransaction2) + } + 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..5a89153081 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt @@ -0,0 +1,300 @@ +package com.tangem.tests.send.confirmScreen + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.* +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SendConfirmScreenTest : BaseTestCase() { + + @AllureId("4003") + @DisplayName("Send (Confirm screen): change sending amount") + @Test + fun changeSendingAmountTest() { + val tokenName = "Ethereum" + val inputAmount = "1" + val newInputAmount = "0.9" + val ethereumAmount = "1.00" + val fiatAmount = "$2,535.63" + val newFiatAmount = "$2,282.07" + val newEthereumAmount = "0.90" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton().performClick() } + } + step("Type '$inputAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(inputAmount) + } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(fiatAmount) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert token amount = '$ethereumAmount'") { + onSendConfirmScreen { primaryAmount.assertTextContains(ethereumAmount) } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendConfirmScreen { secondaryAmount.assertTextContains(fiatAmount) } + } + step("Click on token amount") { + onSendConfirmScreen { primaryAmount.performClick() } + } + step("Clear input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextClearance() + } + } + step("Type '$newInputAmount' in input text field") { + onSendScreen { + amountInputTextField.performTextReplacement(newInputAmount) + } + } + step("Assert fiat amount = '$newFiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(newFiatAmount) } + } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert token amount = '$newEthereumAmount'") { + onSendConfirmScreen { primaryAmount.assertTextContains(newEthereumAmount) } + } + step("Assert fiat amount = '$newFiatAmount'") { + onSendConfirmScreen { secondaryAmount.assertTextContains(newFiatAmount) } + } + } + } + + @AllureId("552") + @DisplayName("Send (Confirm screen): switch to equivalent") + @Test + fun switchToEquivalentTest() { + val tokenName = "Ethereum" + val inputAmount = "1" + val tokenAmount = "1.00" + val ethereumAmount = "ETH 1.00" + val fiatAmount = "$2,535.63" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton().performClick() } + } + step("Type '$inputAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(inputAmount) + } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(fiatAmount) } + } + step("Click on exchange icon") { + onSendScreen { exchangeIcon.performClick() } + } + step("Assert token amount = '$ethereumAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(ethereumAmount) } + } + step("Click on exchange icon") { + onSendScreen { exchangeIcon.performClick() } + } + step("Assert fiat amount = '$fiatAmount'") { + onSendScreen { equivalentInputAmount.assertTextContains(fiatAmount) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert primary amount = '$tokenAmount'") { + onSendConfirmScreen { primaryAmount.assertTextContains(tokenAmount) } + } + step("Assert secondary amount = '$fiatAmount'") { + onSendConfirmScreen { secondaryAmount.assertTextContains(fiatAmount) } + } + } + } + + @AllureId("553") + @DisplayName("Send (Confirm screen): check fee for blockchain with unknown fee") + @Test + fun checkFeeForBlockchainWithUnknownFeeTest() { + val tokenName = "Polygon" + val inputAmount = "1" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + val currentFeeAmount = "<$0.01" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Swipe up to token") { + swipeVertical(SwipeDirection.UP) + } + step("Open 'Send Confirm' screen for token '$tokenName'") { + openSendConfirmScreen( + tokenName = tokenName, + inputAmount = inputAmount, + recipientAddress = recipientAddress + ) + } + step("Check network fee block") { + checkNetworkFeeBlock(currentFeeAmount = currentFeeAmount, withFeeSelector = true) + } + } + } + + @AllureId("4565") + @DisplayName("Send (Confirm screen): check fee for token with unknown fee") + @Test + fun checkFeeForTokenWithUnknownFeeTest() { + val tokenName = "POL (ex-MATIC)" + val inputAmount = "1" + val recipientAddress = ETHEREUM_RECIPIENT_ADDRESS + val currentFeeAmount = "<$0.01" + val scenarioName = "eth_estimate_gas" + val scenarioState = "UnknownFee" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Swipe up to token") { + swipeVertical(SwipeDirection.UP) + } + step("Open 'Send Confirm' screen for token '$tokenName'") { + openSendConfirmScreen( + tokenName = tokenName, + inputAmount = inputAmount, + recipientAddress = recipientAddress + ) + } + step("Check network fee block") { + checkNetworkFeeBlock(currentFeeAmount = currentFeeAmount, withFeeSelector = true) + } + } + } + + @AllureId("554") + @DisplayName("Send (Confirm screen): check fee warning") + @Test + fun checkFeeWarningTest() { + val tokenName = "Polkadot" + val tokenAmount = "0.1" + val warningTitle = getResourceString(R.string.send_fee_unreachable_error_title) + val warningMessageResId = R.string.send_fee_unreachable_error_text + val currentFeeAmount = "$0.05" + + setupHooks( + additionalAfterSection = { + enableWiFi() + enableMobileData() + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$tokenAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(tokenAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) } + } + step("Turn off internet") { + disableWiFi() + disableMobileData() + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Turn on internet") { + enableWiFi() + enableMobileData() + } + step("Assert 'Network fee info unreachable' warning title is displayed") { + onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() } + } + step("Assert 'Check your internet connection' warning message is displayed") { + onSendConfirmScreen { warningMessage(warningMessageResId).assertIsDisplayed() } + } + step("Assert warning icon is displayed") { + onSendConfirmScreen { warningIcon(warningTitle).assertIsDisplayed() } + } + step("Click on 'Refresh' button") { + waitForIdle() + onSendConfirmScreen { refreshButton.clickWithAssertion() } + } + step("Check network fee block") { + checkNetworkFeeBlock(currentFeeAmount = currentFeeAmount, withFeeSelector = false) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/AzeroWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/AzeroWarningsTest.kt new file mode 100644 index 0000000000..3e4c62024e --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/AzeroWarningsTest.kt @@ -0,0 +1,98 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.AZERO_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class AzeroWarningsTest : BaseTestCase() { + private val tokenName = "Aleph Zero" + private val amountToLeaveLessThanDeposit = "0.099876587544" + private val amountToLeaveGreaterThanDeposit = "0.09" + private val depositAmount = "AZERO 0.0000000005" + + private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title) + private val warningMessage = getResourceString( + R.string.send_notification_existential_deposit_text, depositAmount + ) + + @AllureId("4290") + @DisplayName("Warnings: check deposit warnings in Azero") + @Test + fun checkWarning() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveLessThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveLessThanDeposit) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(AZERO_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + ) + } + step("Click on 'Leave $depositAmount' button") { + onSendConfirmScreen { leaveDepositButton(depositAmount).clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + step("Click on 'Amount' field") { + onSendConfirmScreen { primaryAmount.clickWithAssertion() } + } + step("Type '$amountToLeaveGreaterThanDeposit' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveGreaterThanDeposit) + } + } + step("Click on 'Continue' button") { + onSendScreen { continueButton.clickWithAssertion() } + } + step("Assert 'Existential deposit warning' is not displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/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..1603427b46 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -310,4 +310,8 @@ + + + + diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 16c0de5855..27ddebaece 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 16c0de5855d0a75e285a9c84e616dd020f7da4d1 +Subproject commit 27ddebaece01b8e13a9bbafd51f5ff1d64efc2a6 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..41a70887ba --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/DefaultTrackingContextProxy.kt @@ -0,0 +1,98 @@ +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.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 = "Mobile Wallet", + 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/HotWalletContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt new file mode 100644 index 0000000000..8ed80f5e60 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt @@ -0,0 +1,22 @@ +package com.tangem.tap.common.analytics.paramsInterceptor + +import com.tangem.core.analytics.api.ParamsInterceptor +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +class HotWalletContextInterceptor( + val parent: ParamsInterceptor? = null, +) : ParamsInterceptor { + + override fun id(): String = HotWalletContextInterceptor.id() + + override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = true + + override fun intercept(params: MutableMap) { + params[AnalyticsParam.PRODUCT_TYPE] = "Mobile Wallet" + } + + companion object { + fun id(): String = HotWalletContextInterceptor::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt index 25b67c3ee0..b7309a1366 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt @@ -9,7 +9,7 @@ import com.tangem.domain.models.scan.ScanResponse */ class LinkedCardContextInterceptor( scanResponse: ScanResponse, - val parent: LinkedCardContextInterceptor? = null, + val parent: ParamsInterceptor? = null, ) : ParamsInterceptor { private val contextInterceptor = CardContextInterceptor(scanResponse) diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Analytics.kt b/app/src/main/java/com/tangem/tap/common/extensions/Analytics.kt index e09ba24e78..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..ec16f63f50 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -2,8 +2,13 @@ package com.tangem.tap.data import android.content.Context import com.squareup.moshi.Moshi -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -11,7 +16,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton -import kotlin.text.encodeToByteArray private const val DEFAULT_KEY = "tangem_pay_default_key" private const val ORDER_ID_KEY = "tangem_pay_order_id_key" @@ -19,7 +23,9 @@ private const val ORDER_ID_KEY = "tangem_pay_order_id_key" @Singleton internal class DefaultTangemPayStorage @Inject constructor( @ApplicationContext applicationContext: Context, + @NetworkMoshi moshi: Moshi, private val dispatcherProvider: CoroutineDispatcherProvider, + private val appPreferencesStore: AppPreferencesStore, ) : TangemPayStorage { private val secureStorage by lazy { @@ -29,14 +35,21 @@ internal class DefaultTangemPayStorage @Inject constructor( name = "tangem_pay_storage", ) } - private val moshi by lazy { - Moshi.Builder() - .add(KotlinJsonAdapterFactory()) - .build() - } private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } + override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) { + withContext(dispatcherProvider.io) { + secureStorage.store(key = createCustomerAddressKey(userWalletId), value = customerWalletAddress) + } + } + + override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? { + return withContext(dispatcherProvider.io) { + secureStorage.getAsString(createCustomerAddressKey(userWalletId)) + } + } + override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) { val json = tokensAdapter.toJson(tokens) @@ -67,14 +80,33 @@ internal class DefaultTangemPayStorage @Inject constructor( secureStorage.get(createOrderIdKey(customerWalletAddress))?.decodeToString(throwOnInvalidSequence = true) } + override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean { + return withContext(dispatcherProvider.io) { + appPreferencesStore.getSyncOrNull( + key = PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), + ) == true + } + } + + override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) { + withContext(dispatcherProvider.io) { + appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), isDone) + } + } + override suspend fun clearOrderId(customerWalletAddress: String) = withContext(dispatcherProvider.io) { secureStorage.delete(createOrderIdKey(customerWalletAddress)) } - override suspend fun clearAll(customerWalletAddress: String) = withContext(dispatcherProvider.io) { - secureStorage.delete(createKey(customerWalletAddress)) - secureStorage.delete(createOrderIdKey(customerWalletAddress)) - } + override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) = + withContext(dispatcherProvider.io) { + secureStorage.delete(createCustomerAddressKey(userWalletId)) + secureStorage.delete(createKey(customerWalletAddress)) + secureStorage.delete(createOrderIdKey(customerWalletAddress)) + appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false) + } + + private fun createCustomerAddressKey(userWalletId: UserWalletId): String = userWalletId.stringValue private fun createKey(address: String): String = "${DEFAULT_KEY}_$address" diff --git a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt index 1ee8ec2a46..1f40496096 100644 --- a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt +++ b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt @@ -1,14 +1,18 @@ package com.tangem.tap.di -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader +import com.tangem.datasource.api.moonpay.MoonPayApi +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.scanCard.repository.DefaultScanCardRepository import com.tangem.tap.network.exchangeServices.DefaultRampManager +import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService import com.tangem.tap.proxy.AppStateHolder import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -43,17 +47,15 @@ internal object ActivityModule { @Singleton fun provideDefaultRampManager( appStateHolder: AppStateHolder, - expressServiceLoader: ExpressServiceLoader, + expressServiceFetcher: ExpressServiceFetcher, currenciesRepository: CurrenciesRepository, - excludedBlockchains: ExcludedBlockchains, dispatchers: CoroutineDispatcherProvider, ): RampStateManager { return DefaultRampManager( sellService = Provider { requireNotNull(appStateHolder.sellService) }, - expressServiceLoader = expressServiceLoader, + expressServiceFetcher = expressServiceFetcher, currenciesRepository = currenciesRepository, dispatchers = dispatchers, - excludedBlockchains = excludedBlockchains, ) } @@ -63,4 +65,19 @@ internal object ActivityModule { fun provideActivityDelayedWorkCoroutineScope(): CoroutineScope { return CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + @Provides + @Singleton + fun provideExchangeService( + environmentConfigStorage: EnvironmentConfigStorage, + getSelectedWalletUseCase: GetSelectedWalletUseCase, + moonPayApi: MoonPayApi, + ): SellService { + return MoonPayService( + api = moonPayApi, + apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().moonPayApiKey }, + secretKeyProvider = Provider { environmentConfigStorage.getConfigSync().moonPayApiSecretKey }, + userWalletProvider = { getSelectedWalletUseCase.sync().getOrNull() }, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/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..90f6777934 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,11 @@ package com.tangem.tap.di.domain import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.fetcher.SingleAccountListFetcher import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.domain.account.usecase.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -50,10 +53,12 @@ internal object AccountDomainModule { fun provideRecoverCryptoPortfolioUseCase( accountsCRUDRepository: AccountsCRUDRepository, mainAccountTokensMigration: MainAccountTokensMigration, + cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, ): RecoverCryptoPortfolioUseCase { return RecoverCryptoPortfolioUseCase( crudRepository = accountsCRUDRepository, mainAccountTokensMigration = mainAccountTokensMigration, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, ) } @@ -82,4 +87,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/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..d449675c38 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,12 @@ internal object WalletsDomainModule { dispatchers = dispatcherProvider, ) } + + @Provides + @Singleton + fun provideHasSecuredWalletsUseCase( + userWalletsListRepository: UserWalletsListRepository, + ): HasSecuredWalletsUseCase { + return HasSecuredWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + } } \ 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..000ae515ec --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateAddressAndSignChallengeTask.kt @@ -0,0 +1,130 @@ +package com.tangem.tap.domain.tasks.visa + +import arrow.core.getOrElse +import com.tangem.common.CompletionResult +import com.tangem.common.card.CardWallet +import com.tangem.common.card.EllipticCurve +import com.tangem.common.core.CardSession +import com.tangem.common.core.CardSessionRunnable +import com.tangem.common.core.CompletionCallback +import com.tangem.common.core.TangemSdkError +import com.tangem.core.error.ext.tangemError +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.error.VisaActivationError +import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet +import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet +import com.tangem.domain.visa.model.sign +import com.tangem.operations.derivation.DeriveWalletPublicKeyTask +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class TangemPayGenerateAddressAndSignChallengeTask @AssistedInject constructor( + @Assisted private val coroutineScope: CoroutineScope, + private val dispatchersProvider: CoroutineDispatcherProvider, + private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource, +) : CardSessionRunnable { + + override fun run(session: CardSession, callback: CompletionCallback) { + coroutineScope.launch { + callback(runSuspend(session = session)) + } + } + + private suspend fun runSuspend(session: CardSession): CompletionResult { + val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } + ?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError) + + val derivationResult = runDerivationTask(session, wallet) + val address = when (derivationResult) { + is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error) + is CompletionResult.Success -> generateAddressFromExtendedKey(derivationResult.data) + } + + val challenge = withContext(dispatchersProvider.io) { + visaAuthRemoteDataSource.getCustomerWalletAuthChallenge(address) + }.getOrElse { return CompletionResult.Failure(it.tangemError) } + + val dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge) + val approveResult = runVisaCustomerWalletApproveTask( + session = session, + cardId = card.cardId, + targetAddress = address, + dataToSign = dataToSign, + ) + val signedData = when (approveResult) { + is CompletionResult.Failure<*> -> return CompletionResult.Failure(approveResult.error) + is CompletionResult.Success -> approveResult.data + } + + val authTokens = withContext(dispatchersProvider.io) { + visaAuthRemoteDataSource.getTokenWithCustomerWallet( + sessionId = challenge.session.sessionId, + signature = signedData.signature, + nonce = signedData.dataToSign.hashToSign, + ) + }.getOrNull() ?: return CompletionResult.Failure(VisaActivationError.FailedRemoteState.tangemError) + + return CompletionResult.Success( + data = TangemPayInitialCredentials( + customerWalletAddress = address, + authTokens = authTokens, + ), + ) + } + + private suspend fun runDerivationTask( + session: CardSession, + wallet: CardWallet, + ): CompletionResult { + val deferred = CompletableDeferred>() + val derivationTask = DeriveWalletPublicKeyTask( + walletPublicKey = wallet.publicKey, + derivationPath = VisaUtilities.customDerivationPath, + ) + + derivationTask.run(session = session, callback = deferred::complete) + return deferred.await() + } + + private suspend fun runVisaCustomerWalletApproveTask( + session: CardSession, + cardId: String, + targetAddress: String, + dataToSign: VisaDataToSignByCustomerWallet, + ): CompletionResult { + val deferred = CompletableDeferred>() + val task = VisaCustomerWalletApproveTask( + visaDataForApprove = VisaCustomerWalletApproveTask.Input( + cardId = cardId, + targetAddress = targetAddress, + hashToSign = dataToSign.hashToSign, + sign = dataToSign::sign, + ), + ) + task.run(session = session, callback = deferred::complete) + return deferred.await() + } + + private fun generateAddressFromExtendedKey(extendedPublicKey: ExtendedPublicKey): String { + val derivationData = VisaUtilities.visaBlockchain.makeAddressesFromExtendedPublicKey( + extendedPublicKey = extendedPublicKey, + cachedIndex = null, + ) + return derivationData.address + } + + @AssistedFactory + interface Factory { + fun create(coroutineScope: CoroutineScope): TangemPayGenerateAddressAndSignChallengeTask + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/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..7532173467 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 @@ -18,6 +18,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 +28,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 +47,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 +123,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 +158,7 @@ internal class DefaultUserWalletsListRepository( val encryptionKey = userWallet.encryptionKey ?: raise(SetLockError.UserWalletLocked) - runCatching { + runSuspendCatching { userWalletEncryptionKeysRepository.save( encryptionKey = UserWalletEncryptionKey( walletId = userWalletId, @@ -193,6 +202,8 @@ internal class DefaultUserWalletsListRepository( userWalletEncryptionKeysRepository.delete(userWalletIds) + removeHotWalletsFromSDK(userWalletIds) + userWallets.update { currentWallets -> val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } selectedUserWallet.update { currentSelected -> @@ -232,7 +243,7 @@ internal class DefaultUserWalletsListRepository( val encryptionKey = requestPasswordRecursive( hotWalletId = userWallet.hotWalletId, block = { password -> - runCatching { + runSuspendCatching { userWalletEncryptionKeysRepository.getEncryptedWithPassword(userWalletId, password) }.onFailure { raise(UnlockWalletError.UnableToUnlock) @@ -287,9 +298,13 @@ internal class DefaultUserWalletsListRepository( } 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] @@ -309,14 +324,15 @@ internal class DefaultUserWalletsListRepository( removePasswordAttempts(it) } - // if we cant unlock all wallets - if (userWalletIds.all { it in unlockedWalletsIds }.not()) { + // 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) } sensitiveInformationRepository.getAll(allKeys) .doOnSuccess { sensitiveInfo -> - updateWallets { userWallets.updateWith(sensitiveInfo) } + updateWallets { wallets -> wallets?.updateWith(sensitiveInfo) } } .doOnFailure { raise(UnlockWalletError.UnableToUnlock) } } @@ -345,6 +361,33 @@ 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)) + } + } + + 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?, 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/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index d9d1ad29f8..2df9cb3974 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 @@ -10,6 +10,7 @@ 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 @@ -48,6 +49,7 @@ 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, @@ -114,7 +116,8 @@ 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( @@ -126,7 +129,7 @@ internal class AppSettingsModel @Inject constructor( add( itemsFactory.createRequireAccessCodeSwitch( - isChecked = state.requireAccessCode, + isChecked = state.requireAccessCode || !state.useBiometricAuthentication, isEnabled = canUseBiometrics && state.useBiometricAuthentication, onCheckedChange = ::onRequireAccessCodeToggled, ), @@ -206,14 +209,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, @@ -323,6 +324,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..8a3d691e60 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 @@ -41,9 +38,7 @@ 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 @@ -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() { @@ -181,7 +177,7 @@ internal class MainViewModel @Inject constructor( .mapLeft { emptyFlow() } .onRight { wallet -> wallet.distinctUntilChanged() - .onEach { Analytics.setContext(it) } + .onEach { trackingContextProxy.setContext(it) } .flowOn(dispatchers.io) .launchIn(viewModelScope) } @@ -195,22 +191,12 @@ internal class MainViewModel @Inject constructor( private fun initializeOffRamp() { viewModelScope.launch { - val sellService = makeSellExchangeService(environmentConfig = environmentConfigStorage.getConfigSync()) appStateHolder.sellService = sellService sellService.update() } } - private fun makeSellExchangeService(environmentConfig: EnvironmentConfig): ExchangeService { - return MoonPayService( - apiKey = environmentConfig.moonPayApiKey, - secretKey = environmentConfig.moonPayApiSecretKey, - isLogEnabled = LogConfig.network.moonPayService, - userWalletProvider = { getSelectedWalletUseCase.sync().getOrNull() }, - ) - } - private fun observeFlips() { listenToFlipsUseCase().launchIn(viewModelScope) } diff --git a/app/src/main/java/com/tangem/tap/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..3b8a4de86d 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -36,9 +36,10 @@ import com.tangem.features.send.v2.api.SendComponent import com.tangem.features.send.v2.api.SendEntryPointComponent import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent -import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent -import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.ContinueOnboarding +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.Deeplink import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent @@ -62,6 +63,7 @@ internal class ChildFactory @Inject constructor( private val detailsComponentFactory: DetailsComponent.Factory, private val walletSettingsComponentFactory: WalletSettingsComponent.Factory, private val walletBackupComponentFactory: WalletBackupComponent.Factory, + private val walletHardwareBackupComponentFactory: WalletHardwareBackupComponent.Factory, private val disclaimerComponentFactory: DisclaimerComponent.Factory, private val manageTokensComponentFactory: ManageTokensComponent.Factory, private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, @@ -100,6 +102,7 @@ internal class ChildFactory @Inject constructor( private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory, private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory, private val createWalletStartComponentFactory: CreateWalletStartComponent.Factory, + private val createHardwareWalletComponentFactory: CreateHardwareWalletComponent.Factory, private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory, private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory, private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory, @@ -107,8 +110,9 @@ internal class ChildFactory @Inject constructor( private val createWalletBackupComponentFactory: CreateWalletBackupComponent.Factory, private val updateAccessCodeComponentFactory: UpdateAccessCodeComponent.Factory, private val viewPhraseComponentFactory: ViewPhraseComponent.Factory, + private val forgetWalletComponentFactory: ForgetWalletComponent.Factory, private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, - private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory, + private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, @@ -138,7 +142,6 @@ internal class ChildFactory @Inject constructor( is AppRoute.ManageTokens -> { val source = when (route.source) { AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS - AppRoute.ManageTokens.Source.ONBOARDING -> ManageTokensSource.ONBOARDING AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES } @@ -187,6 +190,15 @@ internal class ChildFactory @Inject constructor( componentFactory = walletBackupComponentFactory, ) } + is AppRoute.WalletHardwareBackup -> { + createComponentChild( + context = context, + params = WalletHardwareBackupComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = walletHardwareBackupComponentFactory, + ) + } is AppRoute.MarketsTokenDetails -> { createComponentChild( context = context, @@ -294,7 +306,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 +321,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 +516,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 +551,7 @@ internal class ChildFactory @Inject constructor( context = context, params = WalletActivationComponent.Params( userWalletId = route.userWalletId, + isBackupExists = route.isBackupExists, ), componentFactory = walletActivationComponentFactory, ) @@ -534,6 +561,8 @@ internal class ChildFactory @Inject constructor( context = context, params = CreateWalletBackupComponent.Params( userWalletId = route.userWalletId, + isUpgradeFlow = route.isUpgradeFlow, + setAccessCode = route.setAccessCode, ), componentFactory = createWalletBackupComponentFactory, ) @@ -556,6 +585,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 +643,11 @@ internal class ChildFactory @Inject constructor( is AppRoute.TangemPayDetails -> { createComponentChild( context = context, - params = TangemPayDetailsComponent.Params(config = route.config), - componentFactory = tangemPayDetailsComponentFactory, + params = TangemPayDetailsContainerComponent.Params( + userWalletId = route.userWalletId, + config = route.config, + ), + componentFactory = tangemPayDetailsContainerComponentFactory, ) } is AppRoute.TangemPayOnboarding -> { diff --git a/build.gradle.kts b/build.gradle.kts index 3648de0c58..b59181ab1b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,18 +34,36 @@ interface Injected { // Test Logging subprojects { - tasks.withType { + tasks.withType().configureEach { + val taskName = name.lowercase() + if (taskName.contains("external") || + taskName.contains("internal") || + taskName.contains("release") || + taskName.contains("mocked") || + taskName.contains("huawei") + ) { + enabled = false + println("Skipping test task: $name") + } else { + println("Test task scheduled: $name") + } + testLogging { exceptionFormat = TestExceptionFormat.FULL showStandardStreams = true afterSuite(KotlinClosure2({ desc, result -> if (desc.parent == null) { // will match the outermost suite - val output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" + val output = + "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" val startItem = "| " val endItem = " |" val repeatLength = startItem.length + output.length + endItem.length - println("\n" + "-".repeat(repeatLength) + "\n" + startItem + output + endItem + "\n" + "-".repeat(repeatLength)) + println( + "\n" + "-".repeat(repeatLength) + "\n" + startItem + output + endItem + "\n" + "-".repeat( + repeatLength + ) + ) } })) } diff --git a/common/routing/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..850968f92a 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,11 +358,14 @@ 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 isUpgradeFlow: Boolean = false, + val setAccessCode: Boolean = false, ) : AppRoute(path = "/create_wallet_backup/${userWalletId.stringValue}") @Serializable @@ -355,6 +378,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 +413,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class TangemPayDetails( + val userWalletId: UserWalletId, val config: TangemPayDetailsConfig, - ) : AppRoute(path = "/tangem_pay_details") + ) : AppRoute(path = "/tangem_pay_details/${userWalletId.stringValue}") @Serializable data class TangemPayOnboarding( diff --git a/common/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/detekt-baseline-debug.xml b/common/test/detekt-baseline-debug.xml new file mode 100644 index 0000000000..27ff65dbe7 --- /dev/null +++ b/common/test/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + CastNullableToNonNullableType:MockScanResponseFactory.kt$MockScanResponseFactory$as + MultilineLambdaItParameter:MockScanResponseFactory.kt$MockScanResponseFactory${ CardDTO.Wallet( CardWallet( publicKey = it.name.toByteArray(), // IMPORTANT: public key must equal to curve name chainCode = null, curve = it, settings = createSettings(), totalSignedHashes = null, remainingSignatures = null, index = 0, isImported = true, hasBackup = true, derivedKeys = emptyMap(), ), ) } + + diff --git a/common/ui-charts/detekt-baseline-debug.xml b/common/ui-charts/detekt-baseline-debug.xml new file mode 100644 index 0000000000..05694da92a --- /dev/null +++ b/common/ui-charts/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + SuspendFunSwallowedCancellation:MarketChartDataProducer.kt$MarketChartDataProducer$runCatching + + 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/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 { @@ -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/detekt-baseline-debug.xml b/core/ab-tests/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d3bf1ceb5e --- /dev/null +++ b/core/ab-tests/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + NullableToStringCall:AmplitudeABTestsManager.kt$AmplitudeABTestsManager$${variant.key} + NullableToStringCall:AmplitudeABTestsManager.kt$AmplitudeABTestsManager$${variant.payload} + + 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..b1af356975 --- /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}") + Timber.d(" → Key: ${variant.key}") + 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..9fa02703d3 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -84,6 +84,7 @@ sealed class AnalyticsParam { data object LongTap : ScreensSources("Long Tap") data object Markets : ScreensSources("Markets") data object HotWallet : ScreensSources("Hot Wallet") + data object TangemPay : ScreensSources("Tangem Pay") } sealed class TxSentFrom(val value: String) { diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/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..cd3fce3406 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" @@ -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..8b69a14c9d 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,12 @@ sealed class ApiConfig { Express, TangemTech, StakeKit, + P2PEthPool, TangemPay, BlockAid, YieldSupply, + MoonPay, + News, } private fun initializeId(): ID { @@ -34,9 +37,12 @@ sealed class ApiConfig { is Express -> ID.Express is TangemTech -> ID.TangemTech is StakeKit -> ID.StakeKit + is P2PEthPool -> ID.P2PEthPool is TangemPay -> ID.TangemPay 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/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..044c788bc7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -7,6 +7,7 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -146,4 +147,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/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/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/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 65a28d997e..63671044ad 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 @@ -28,10 +28,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( 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/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..dbee720cef 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -137,6 +137,9 @@ interface TangemTechApi { @GET("v1/user-wallets/wallets/by-app/{app_id}") suspend fun getWallets(@Path("app_id") appId: String): ApiResponse> + + @POST("v1/user-wallets/wallets") + suspend fun createWallet(@Body body: WalletIdBody): ApiResponse // endregion // promo 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..b1b4d31f95 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") @@ -68,4 +71,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/WalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt index 3fd5ff6196..d8908b2d9f 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,16 @@ 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, -) \ No newline at end of file + @Json(name = "type") val walletType: WalletType? = null, + @Json(name = "cards") val cards: List? = null, +) { + + @JsonClass(generateAdapter = false) + enum class WalletType { + @Json(name = "card") + COLD, + + @Json(name = "mobile") + HOT, + } +} \ 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..052decff8c 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( @@ -74,4 +85,10 @@ internal object ApiConfigsModule { fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig { return BlockAid(environmentConfigStorage) } + + @Provides + @IntoSet + fun provideMoonPayConfig(): ApiConfig { + return MoonPay() + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 9a0bed416c..dd2041b5f1 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,13 +5,17 @@ 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.stakekit.StakeKitApi import com.tangem.datasource.api.tangemTech.TangemTechApi @@ -71,6 +75,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 { @@ -130,4 +143,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/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..1d4c3967de 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,6 +17,7 @@ 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, 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..e392f5c5dc 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 @@ -121,6 +121,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 +173,9 @@ object PreferencesKeys { fun getHotWalletUnlockDeadlineKey(attemptId: String) = longPreferencesKey(name = "hotWalletUnlockDeadline_$attemptId") + fun getTangemPayAddToWalletKey(customerWalletAddress: String) = + booleanPreferencesKey("tangem_pay_add_to_wallet_done_key_$customerWalletAddress") + // endregion } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/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..d74074f011 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -1,9 +1,13 @@ package com.tangem.datasource.local.visa +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.model.VisaAuthTokens interface TangemPayStorage { + suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) + suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? + suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? @@ -14,5 +18,9 @@ interface TangemPayStorage { suspend fun clearOrderId(customerWalletAddress: String) - suspend fun clearAll(customerWalletAddress: String) + suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean + + suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) + + suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 0aa707e8cb..693c666ff7 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,9 @@ 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) } } } 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..bac344e79b 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,9 @@ 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) } } } @@ -121,6 +127,9 @@ 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() } } @@ -239,7 +248,7 @@ 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" }, @@ -263,6 +272,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 +338,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/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/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 6c0c43f6d2..ba7cf8ed61 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -167,7 +167,7 @@ All Tangem devices have been reset. You can now continue upgrading your wallet. Upgrade again Reset complete - You haven’t reset all your Tangem devices + We recommend completing the reset process for all Tangem devices in this wallet. You haven’t reset all your Tangem devices Disable this option if you don\'t want this card to be used to reset access codes on other cards or rings in this wallet. Please note that this will also prevent you from resetting the access code on this card. Allows you to use this card to reset access code on other cards in this wallet @@ -275,6 +275,7 @@ Forget Free From + From %s Synchronize addresses Get started Get token @@ -300,12 +301,13 @@ %d networks New address + News Next NFT No No address Not Added - Not Now + Not now Now OK Open in Browser @@ -357,6 +359,7 @@ terms and conditions Terms of Use To + To %s Today %d token @@ -519,6 +522,9 @@ ID: %s Transaction ID copied Swap any asset in your portfolio for this token + Market & News + Tangem AI + Trending Now The following information is optional. You can erase it if you don\'t want to share it. Tell us what functions you are missing, and we will try to help you. Please tell us what card or ring do you have @@ -605,7 +611,7 @@ Forget this wallet? I understand that if I haven\'t backed up my wallet before removing it, I may lose access to it. I understand that removing my wallet does not delete it—but simply removes it from my device. - No seed phrase is needed anymore — your Tangem card or ring becomes your secure backup. + No seed phrase is needed anymore. Your Tangem card or ring becomes your secure backup. Backup with Tangem Can’t upgrade. A wallet already exists on this device. Pick another device. This one can’t be used for the upgrade. @@ -772,6 +778,8 @@ Volume Pull this up or tap the search bar to add tokens directly from the market Add tokens + All news + Stay in the loop NFC is not available on your device About NFT NFT asset @@ -1070,7 +1078,7 @@ You specified a commission below the recommended amount, which could cause a delay in your transaction. Continue? Reason: %1$s\nCode: %2$s The transaction is not completed - Convert to another token + Swap to another token or network Amount Will be sent to recipient You can set your transaction fee by adjusting the value in the Satoshi per vByte field. @@ -1651,7 +1659,7 @@ Using a Tangem Wallet already? Scan now Pick a wallet setup method - Ready to get a Tangem Wallet? + Want to purchase Tangem Wallet? Buy now Recover existing wallet via Google Drive backup Import from Google Drive @@ -1673,7 +1681,7 @@ Get now with 10% off Access 13,000+ cryptocurrencies. Buy, sell, swap, and stake with a single tap.\nLink up to three cards for a backup. Discover Tangem Wallet - This secret code protects your wallet and is used to log in and sign transactions. + This access code protects your wallet and is used to log in and sign transactions. Set/Change access code Change access code Stay notified on wallet incoming transactions and Tangem updates. @@ -1869,7 +1877,7 @@ URI already used WalletConnect Suspicious transaction - Already have Tangem? + Already have Tangem Wallet? Thousands of assets Best in class hardware wallet Fast delivery @@ -1878,7 +1886,7 @@ Simple to use Create a hardware wallet with Tangem. Slim as a bank card, secure as a bank vault. Create or import a software wallet - Create or import a software wallet on your phone + Create or import a software wallet on your phone. Start with Mobile Wallet Other method Use Tangem Hardware Wallet @@ -1899,7 +1907,7 @@ To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ When Yield Mode is active, all future top-ups to this address will be supplied to Aave. You can still manage your funds freely. Your %s is supplied to Aave - Supplying %1$s %2$s to Aave is pending + Supplying %1$s %2$s to Aave Approve Your token\'s approval has been revoked. Grant it again to resume the service\'s functionality. Approve needed @@ -1975,9 +1983,9 @@ Yield Mode Enabling Yield Mode Yield Mode - Yield mode on - Yield mode off - Yield mode top-up + Yield Mode on + Yield Mode off + Yield Mode top-up Automatic Add some %1$s %2$s to cover the network fee for transactions. Unable to cover %s fee 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/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_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_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_frozen_tangem_pay_visa.webp b/core/ui/src/main/res/drawable/img_frozen_tangem_pay_visa.webp new file mode 100644 index 0000000000..464da3a41e Binary files /dev/null and b/core/ui/src/main/res/drawable/img_frozen_tangem_pay_visa.webp differ 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..2ceacdaa2b 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/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..b1e2281ba5 --- /dev/null +++ b/data/account/detekt-baseline-debug.xml @@ -0,0 +1,14 @@ + + + + + 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:FetchWalletAccountsErrorHandler.kt$FetchWalletAccountsErrorHandler${ it.copy( tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = it.tokens), ) } + 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..208bdb7817 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,18 +52,25 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : WalletAccountsFetcher, WalletAccountsSaver { - override suspend fun fetch(userWalletId: UserWalletId) { + override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse { val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse) - ?: return - if (accountsResponse.accounts.isEmpty()) { - initializeAccounts(userWalletId, accountsResponse) - } else if (accountsResponse.unassignedTokens.isNotEmpty()) { - assignTokens(userWalletId, accountsResponse) + return when { + accountsResponse.accounts.isEmpty() -> { + initializeAccounts(userWalletId, accountsResponse) + } + accountsResponse.unassignedTokens.isNotEmpty() -> { + assignTokens(userWalletId, accountsResponse) + } + else -> accountsResponse } } + override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? { + return getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() + } + override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { val store = getAccountsResponseStore(userWalletId = userWalletId) @@ -115,7 +123,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private suspend fun fetchWalletAccounts( userWalletId: UserWalletId, savedAccountsResponse: GetWalletAccountsResponse?, - ): GetWalletAccountsResponse? { + ): GetWalletAccountsResponse { return safeApiCall( call = { val apiResponse = withContext(dispatchers.io) { @@ -145,33 +153,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? { 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..c4b9d76100 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,55 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory -import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.WalletIdBody +import com.tangem.datasource.api.tangemTech.models.WalletIdBody.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 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,23 +69,27 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( savedAccountsResponse: GetWalletAccountsResponse?, pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse?, storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit, - ): GetWalletAccountsResponse? { + ): GetWalletAccountsResponse { val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) if (isResponseUpToDate) { Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") - return savedAccountsResponse + return requireNotNull(savedAccountsResponse) { + "Saved accounts response is null for wallet: $userWalletId" + } } - var response = savedAccountsResponse ?: createDefaultResponse(userWalletId) + val response = savedAccountsResponse ?: createDefaultResponse(userWalletId) val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse() val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) if (isNotFoundError) { - userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) - val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs) + val eTag = createWallet(userWalletId) - if (updatedResponse != null) { - response = updatedResponse + if (eTag != null) { + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag) + + pushWalletAccounts(userWalletId, accountDTOs) + userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse) } } @@ -90,4 +114,33 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( } .also { userTokensResponseStore.clear(userWalletId) } } + + /** + * Creates a wallet on the server and returns the ETag if successful. + * + * @param userWalletId The ID of the user wallet to create. + + */ + private suspend fun createWallet(userWalletId: UserWalletId): String? { + val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null + + val creationResponse = withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = WalletIdBody( + walletId = userWalletId.stringValue, + name = userWallet.name, + walletType = when (userWallet) { + is UserWallet.Cold -> WalletType.COLD + is UserWallet.Hot -> WalletType.HOT + }, + ), + ) + } + + 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..2bae8a091f 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) { @@ -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..e3ffe9e2a0 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -199,7 +199,7 @@ class DefaultWalletAccountsFetcherTest { @Test fun `fetch should call error handler when getWalletAccounts returns error`() = runTest { // Arrange - val savedAccountsResponse = null + val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId) val apiError = ApiResponse.Error(ApiResponseError.NetworkException()) accountsResponseStoreFlow.value = savedAccountsResponse @@ -212,7 +212,7 @@ class DefaultWalletAccountsFetcherTest { fetchWalletAccountsErrorHandler.handle( error = apiError.cause, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = savedAccountsResponse, pushWalletAccounts = any(), storeWalletAccounts = any(), ) @@ -231,7 +231,7 @@ class DefaultWalletAccountsFetcherTest { fetchWalletAccountsErrorHandler.handle( error = apiError.cause, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = savedAccountsResponse, pushWalletAccounts = any(), storeWalletAccounts = any(), ) diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index 6500556de8..e4495ab395 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,24 @@ 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.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 +32,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 +65,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 +77,7 @@ class FetchWalletAccountsErrorHandlerTest { handler.handle( error = error, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = response, pushWalletAccounts = pushWalletAccounts, storeWalletAccounts = storeWalletAccounts, ) @@ -73,7 +87,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 +105,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 = WalletIdBody.WalletType.COLD, + ), + ) + } returns apiResponse coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse // Act @@ -104,7 +141,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 = WalletIdBody.WalletType.COLD, + ), + ) + eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue) pushWalletAccounts(userWalletId, listOf(accountDTO)) storeWalletAccounts(userWalletId, savedAccountsResponse) } @@ -166,7 +211,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/detekt-baseline-debug.xml b/data/balance-hiding/detekt-baseline-debug.xml new file mode 100644 index 0000000000..eca9bec2a0 --- /dev/null +++ b/data/balance-hiding/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + CastNullableToNonNullableType:DefaultDeviceFlipDetector.kt$DefaultDeviceFlipDetector$as + + 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..990995357d 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 @@ -8,7 +8,10 @@ import com.tangem.datasource.local.token.UserTokensResponseStore 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 class UserTokensSaver( private val tangemTechApi: TangemTechApi, @@ -16,6 +19,7 @@ class UserTokensSaver( private val dispatchers: CoroutineDispatcherProvider, private val addressesEnricher: UserTokensResponseAddressesEnricher, private val accountsFeatureToggles: AccountsFeatureToggles, + private val pushTokensRetryerPool: RetryerPool, ) { private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility() @@ -58,6 +62,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 +91,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 +107,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..8158a250c7 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, ) @@ -71,6 +79,9 @@ internal object DataCommonModule { 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..1b5bdab260 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 @@ -30,6 +30,7 @@ class UserTokensSaverTest { dispatchers = TestingCoroutineDispatcherProvider(), addressesEnricher = enricher, accountsFeatureToggles = accountsFeatureToggles, + pushTokensRetryerPool = mockk(), ) @BeforeEach 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/detekt-baseline-debug.xml b/data/express/detekt-baseline-debug.xml new file mode 100644 index 0000000000..0be5181121 --- /dev/null +++ b/data/express/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + IgnoredReturnValue:DefaultExpressServiceFetcher.kt$DefaultExpressServiceFetcher$put(key = userWalletId, value = default) + MultilineLambdaItParameter:DefaultExpressRepository.kt$DefaultExpressRepository${ Timber.w(it, "Unable to fetch express providers") throw it } + + diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt b/data/express/src/main/java/com/tangem/data/express/DefaultExpressServiceFetcher.kt similarity index 65% rename from core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt rename to data/express/src/main/java/com/tangem/data/express/DefaultExpressServiceFetcher.kt index 11192e7af9..65e0556973 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt +++ b/data/express/src/main/java/com/tangem/data/express/DefaultExpressServiceFetcher.kt @@ -1,17 +1,23 @@ -package com.tangem.datasource.exchangeservice.swap +package com.tangem.data.express +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.data.express.converter.ExpressAssetConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.request.AssetsRequestBody import com.tangem.datasource.api.express.models.request.LeastTokenInfo -import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.exchangeservice.swap.ExpressUtils.getRefCode import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.ExpressAssetsStore +import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.utils.catchOn import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -19,56 +25,72 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject -typealias InitializationStatusFlow = MutableStateFlow>> +typealias InitializationStatusFlow = MutableStateFlow>> /** - * Default implementation of [ExpressServiceLoader] + * Default implementation of [ExpressServiceFetcher] * * @property tangemExpressApi express api * @property expressAssetsStore local storage * [REDACTED_AUTHOR] */ -internal class DefaultExpressServiceLoader @Inject constructor( +internal class DefaultExpressServiceFetcher @Inject constructor( private val tangemExpressApi: TangemExpressApi, private val expressAssetsStore: ExpressAssetsStore, private val appPreferencesStore: AppPreferencesStore, + private val userWalletsStore: UserWalletsStore, private val dispatchers: CoroutineDispatcherProvider, -) : ExpressServiceLoader { +) : ExpressServiceFetcher { private val initializationStatuses = MutableStateFlow>(value = emptyMap()) - override suspend fun update(userWallet: UserWallet, userTokens: List) { - withContext(dispatchers.io) { + override suspend fun fetch(userWalletId: UserWalletId, assetIds: Set): Either = + either { + val userWallet = arrow.core.raise.catch( + block = { userWalletsStore.getSyncStrict(userWalletId) }, + catch = ::raise, + ) + + fetch(userWallet = userWallet, assetIds = assetIds).bind() + } + + override suspend fun fetch(userWallet: UserWallet, assetIds: Set): Either { + return Either.catchOn(dispatchers.io) { val initializationStatus = getInitializationStatusInternal(userWallet.walletId) try { - if (userTokens.isNotEmpty()) { + if (assetIds.isNotEmpty()) { + val tokenList = assetIds.map { + LeastTokenInfo(contractAddress = it.contractAddress, network = it.networkId) + } + val response = tangemExpressApi.getAssets( userWalletId = userWallet.walletId.stringValue, refCode = getRefCode(userWallet, appPreferencesStore), - body = AssetsRequestBody(tokensList = userTokens), + body = AssetsRequestBody(tokensList = tokenList), ).getOrThrow() expressAssetsStore.store(userWallet.walletId, response) - initializationStatus.update { response.lceContent() } + val expressAssets = ExpressAssetConverter.convertList(response) + initializationStatus.update { expressAssets.lceContent() } } } catch (e: Throwable) { if (expressAssetsStore.getSyncOrNull(userWallet.walletId) == null) { initializationStatus.update { e.lceError() } } Timber.e(e, "Unable to fetch assets for: ${userWallet.walletId.stringValue}") + throw e } } } - override fun getInitializationStatus(userWalletId: UserWalletId): Flow>> { + override fun getInitializationStatus(userWalletId: UserWalletId): Flow>> { return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } } } @@ -77,7 +99,7 @@ internal class DefaultExpressServiceLoader @Inject constructor( val initializationStatus = initializationStatuses.value[userWalletId] if (initializationStatus != null) return initializationStatus - val cached = expressAssetsStore.getSyncOrNull(userWalletId) + val cached = expressAssetsStore.getSyncOrNull(userWalletId)?.let(ExpressAssetConverter::convertList) val default: InitializationStatusFlow = MutableStateFlow(value = cached?.lceContent() ?: lceLoading()) initializationStatuses.update { statuses -> diff --git a/data/express/src/main/java/com/tangem/data/express/converter/ExpressAssetConverter.kt b/data/express/src/main/java/com/tangem/data/express/converter/ExpressAssetConverter.kt new file mode 100644 index 0000000000..9c5ccace5f --- /dev/null +++ b/data/express/src/main/java/com/tangem/data/express/converter/ExpressAssetConverter.kt @@ -0,0 +1,24 @@ +package com.tangem.data.express.converter + +import com.tangem.datasource.api.express.models.response.Asset +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.utils.converter.Converter + +/** + * Converts an [Asset] from the data layer to an [ExpressAsset] in the domain layer. + * +[REDACTED_AUTHOR] + */ +internal object ExpressAssetConverter : Converter { + + override fun convert(value: Asset): ExpressAsset { + return ExpressAsset( + id = ExpressAsset.ID( + networkId = value.network, + contractAddress = value.contractAddress, + ), + isExchangeAvailable = value.exchangeAvailable, + isOnrampAvailable = value.onrampAvailable, + ) + } +} \ No newline at end of file diff --git a/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt b/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt index 299b631162..c696e08aec 100644 --- a/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt +++ b/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt @@ -2,6 +2,7 @@ package com.tangem.data.express.di import com.squareup.moshi.Moshi import com.tangem.data.express.DefaultExpressRepository +import com.tangem.data.express.DefaultExpressServiceFetcher import com.tangem.data.express.converter.ExpressErrorConverter import com.tangem.data.express.error.DefaultExpressErrorResolver import com.tangem.datasource.api.express.TangemExpressApi @@ -10,6 +11,7 @@ import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.express.ExpressErrorResolver import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -43,4 +45,10 @@ internal object ExpressDataModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideExpressServiceFetcher(impl: DefaultExpressServiceFetcher): ExpressServiceFetcher { + return impl + } } \ No newline at end of file diff --git a/data/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/detekt-baseline-debug.xml b/data/hot-wallet/detekt-baseline-debug.xml new file mode 100644 index 0000000000..e0f29a8922 --- /dev/null +++ b/data/hot-wallet/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + MultilineLambdaItParameter:DefaultHotWalletRepository.kt$DefaultHotWalletRepository${ it.setObjectMap( key = PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY, value = it.getObjectMap<Boolean>(PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY) .plus(userWalletId.stringValue to skipped), ) } + + 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..d4064d0089 --- /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 { + it.setObjectMap( + key = PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY, + value = it.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..12ebfeda6d 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,112 @@ internal class DefaultManageTokensRepository( ) } + private suspend fun createManagedCryptoCurrencyList( + params: ManageTokensListConfig.Account, + userWallet: UserWallet?, + isFirstBatchFetching: Boolean, + loadUserTokensFromRemote: Boolean, + query: String?, + updatedCoinsResponse: CoinsResponse, + ): List { + val response = params.userWalletId?.let { userWalletId -> + if (loadUserTokensFromRemote && userWallet != null) { + runCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull() + } else { + walletAccountsFetcher.getSaved(userWalletId) + } + } + + val accountId = when { + params.accountId == null -> null + loadUserTokensFromRemote -> { + AccountId.forCryptoPortfolio( + userWalletId = requireNotNull(params.accountId).userWalletId, + derivationIndex = DerivationIndex.Main, + ) + } + else -> requireNotNull(params.accountId) + } + + val accountDTO = if (response != null && accountId != null) { + response.accounts.firstOrNull { it.id == accountId.value } + } else { + null + } + + val tokensResponse = response?.let { + UserTokensResponse( + group = response.wallet.group.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 +277,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 +345,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/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/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/detekt-baseline-debug.xml b/data/quotes/detekt-baseline-debug.xml new file mode 100644 index 0000000000..c69926fdf1 --- /dev/null +++ b/data/quotes/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + MultilineLambdaItParameter:DefaultMultiQuoteStatusFetcher.kt$DefaultMultiQuoteStatusFetcher${ Timber.e(it) 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/detekt-baseline-debug.xml b/data/settings/detekt-baseline-debug.xml new file mode 100644 index 0000000000..5489dc6b26 --- /dev/null +++ b/data/settings/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + SuspendFunSwallowedCancellation:DefaultSettingsRepository.kt$DefaultSettingsRepository$runCatching + + 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/detekt-baseline-debug.xml b/data/staking/detekt-baseline-debug.xml new file mode 100644 index 0000000000..39dba1e36e --- /dev/null +++ b/data/staking/detekt-baseline-debug.xml @@ -0,0 +1,15 @@ + + + + + MultilineLambdaItParameter:DefaultMultiYieldBalanceFetcher.kt$DefaultMultiYieldBalanceFetcher${ Timber.e(it, "Unable to fetch yield balances $params") yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) } + MultilineLambdaItParameter:DefaultMultiYieldBalanceFetcher.kt$DefaultMultiYieldBalanceFetcher${ Timber.e(it, "Unable to fetch yield balances $userWalletId") yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) throw it } + MultilineLambdaItParameter:DefaultMultiYieldBalanceFetcher.kt$DefaultMultiYieldBalanceFetcher${ request.integrationId == it.integrationId && request.addresses.address == it.addresses.address } + MultilineLambdaItParameter:DefaultMultiYieldBalanceFetcher.kt$DefaultMultiYieldBalanceFetcher${ stakingId.integrationId == it.integrationId && stakingId.address == it.addresses.address } + MultilineLambdaItParameter:DefaultStakingRepository.kt$DefaultStakingRepository${ YieldConverter.convertListIgnoreErrors( input = it, onError = { Timber.e("Error converting one of the items in enabled yields: $it") }, ) } + NamedArguments:DefaultP2PEthPoolRepository.kt$DefaultP2PEthPoolRepository$getRewards(network.value, delegatorAddress, vaultAddress, period) + NoNameShadowing:DefaultStakingRepository.kt$DefaultStakingRepository${ Timber.e("Error converting one of the items in enabled yields: $it") } + NullableToStringCall:DefaultStakingRepository.kt$DefaultStakingRepository$${(response as? ApiResponse.Error)?.cause} + UseEmptyCounterpart:YieldBalanceRequestBodyFactory.kt$YieldBalanceRequestBodyFactory$listOf() + + 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..4c0870aec5 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -0,0 +1,197 @@ +package com.tangem.data.staking + +import arrow.core.Either +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.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.withContext + +/** + * P2P staking repository implementation + */ +internal class DefaultP2PEthPoolRepository( + private val p2pApi: P2PEthPoolApi, + 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 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.value, delegatorAddress, vaultAddress, 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)) + } + } + } +} \ No newline at end of file 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 f6341c0957..7ee32bed6f 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 @@ -94,7 +94,7 @@ internal class DefaultStakingRepository( override suspend fun fetchEnabledYields() { withContext(dispatchers.io) { - val yieldsResponses = getAvailableIntegrationsIds().map { + val yieldsResponses = getAvailableStakeKitIntegrationsIds().map { async { it.getYieldRequest() } }.awaitAll() @@ -157,13 +157,13 @@ internal class DefaultStakingRepository( } } - private suspend fun StakingIntegrationID.getYieldRequest(): ApiResponse { + private suspend fun StakingIntegrationID.StakeKit.getYieldRequest(): ApiResponse { return when (this) { - is StakingIntegrationID.Coin -> stakeKitApi.getEnabledYields( + is StakingIntegrationID.StakeKit.Coin -> stakeKitApi.getEnabledYields( preferredValidatorsOnly = false, network = networkId, ) - is StakingIntegrationID.EthereumToken -> stakeKitApi.getEnabledYields( + is StakingIntegrationID.StakeKit.EthereumToken -> stakeKitApi.getEnabledYields( preferredValidatorsOnly = false, yieldId = value, network = networkId, @@ -171,8 +171,8 @@ internal class DefaultStakingRepository( } } - private fun getAvailableIntegrationsIds(): List { - return StakingIntegrationID.entries.filterNot { + private fun getAvailableStakeKitIntegrationsIds(): List { + return StakingIntegrationID.StakeKit.entries.filterNot { it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled } } @@ -296,6 +296,7 @@ internal class DefaultStakingRepository( return when (networkId.toBlockchain()) { Blockchain.TON -> stakingFeatureToggles.isTonStakingEnabled Blockchain.Cardano -> stakingFeatureToggles.isCardanoStakingEnabled + Blockchain.Ethereum -> stakingFeatureToggles.isEthStakingEnabled else -> true } } 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..cfab0afe66 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,6 +3,7 @@ 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.DefaultP2PEthPoolRepository import com.tangem.data.staking.DefaultStakingActionRepository import com.tangem.data.staking.DefaultStakingErrorResolver import com.tangem.data.staking.DefaultStakingRepository @@ -11,12 +12,14 @@ 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.StakingActionsStore import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.repositories.StakingActionRepository import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository @@ -60,6 +63,18 @@ internal object StakingDataModule { ) } + @Provides + @Singleton + fun provideP2PEthPoolRepository( + p2pApi: P2PEthPoolApi, + dispatchers: CoroutineDispatcherProvider, + ): P2PEthPoolRepository { + return DefaultP2PEthPoolRepository( + p2pApi = p2pApi, + dispatchers = dispatchers, + ) + } + @Provides @Singleton fun provideStakingTransactionHashRepository( 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/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..58bc11680b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -5,20 +5,14 @@ import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.common.currency.UserTokensSaver -import com.tangem.data.tokens.repository.DefaultCurrenciesRepository -import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository -import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository -import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedRepository +import com.tangem.data.tokens.repository.* import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.TokenReceiveWarningActionStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.CurrencyChecksRepository -import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository -import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -40,7 +34,7 @@ internal object TokensDataModule { walletManagersFacade: WalletManagersFacade, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, - expressServiceLoader: ExpressServiceLoader, + expressServiceFetcher: ExpressServiceFetcher, excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, tokensSaver: UserTokensSaver, @@ -52,7 +46,7 @@ internal object TokensDataModule { walletManagersFacade = walletManagersFacade, cacheRegistry = cacheRegistry, userTokensResponseStore = userTokensResponseStore, - expressServiceLoader = expressServiceLoader, + expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, excludedBlockchains = excludedBlockchains, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 80b1a4577d..834125482e 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -10,17 +10,16 @@ import com.tangem.data.common.currency.* import com.tangem.data.tokens.utils.CustomTokensMerger import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE -import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.error.DataError import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -40,7 +39,7 @@ internal class DefaultCurrenciesRepository( private val userWalletsStore: UserWalletsStore, private val walletManagersFacade: WalletManagersFacade, private val cacheRegistry: CacheRegistry, - private val expressServiceLoader: ExpressServiceLoader, + private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, private val userTokensSaver: UserTokensSaver, @@ -49,7 +48,7 @@ internal class DefaultCurrenciesRepository( 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 +73,6 @@ internal class DefaultCurrenciesRepository( userTokensSaver.storeAndPush(userWalletId, response) } - override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) { - withContext(dispatchers.io) { - val savedResponse = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWalletId), - lazyMessage = { "Saved tokens empty. Can not perform add currencies action." }, - ) - - val updatedResponse = savedResponse.copy( - tokens = currencies.map(userTokensResponseFactory::createResponseToken), - ) - - userTokensSaver.store(userWalletId = userWalletId, response = updatedResponse) - - fetchExpressAssetsByNetworkIds( - userWallet = userWalletsStore.getSyncStrict(key = userWalletId), - userTokens = updatedResponse, - ) - } - } - override suspend fun addCurrenciesCache( userWalletId: UserWalletId, currencies: List, @@ -289,7 +268,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,50 +301,6 @@ internal class DefaultCurrenciesRepository( ) } - override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId) = - withContext(dispatchers.io) { - val userWallet = userWalletsStore.getSyncStrict(userWalletId) - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) - - val storedTokens = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWallet.walletId), - lazyMessage = { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - }, - ) - - responseCryptoCurrenciesFactory.createCurrencies( - storedTokens, - userWallet = userWallet, - ) - } - - override suspend fun getMultiCurrencyWalletCurrency( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency = withContext(dispatchers.io) { - getMultiCurrencyWalletCurrency(userWalletId, id.value) - } - - override suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency = - withContext(dispatchers.io) { - val userWallet = userWalletsStore.getSyncStrict(userWalletId) - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) - - val response = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWalletId), - lazyMessage = { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - }, - ) - - responseCryptoCurrenciesFactory.createCurrency( - currencyId = id, - response = response, - userWallet = userWallet, - ) - } - override suspend fun getNetworkCoin( userWalletId: UserWalletId, networkId: Network.ID, @@ -668,15 +603,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 +620,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 +632,7 @@ internal class DefaultCurrenciesRepository( skipCache = refresh, block = { coroutineScope { - launch { expressServiceLoader.update(userWallet, tokens) } + launch { expressServiceFetcher.fetch(userWallet, tokens) } } }, ) @@ -731,6 +666,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/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..0e59eaa831 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(projects.domain.tokens) implementation(projects.domain.networks) implementation(projects.domain.walletManager) + implementation(projects.domain.quotes) /** Feature API - remove after removing [HotWalletFeatureToggles] */ implementation(projects.features.hotWallet.api) diff --git a/data/visa/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..bca25d2bb9 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -4,9 +4,8 @@ import arrow.core.Either import arrow.core.raise.either import com.tangem.common.CompletionResult import com.tangem.domain.pay.datasource.TangemPayAuthDataSource -import com.tangem.domain.visa.model.VisaDataForApprove -import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.sdk.api.TangemSdkManager import javax.inject.Inject @@ -16,29 +15,14 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( private val tangemSdkManager: TangemSdkManager, ) : TangemPayAuthDataSource { - override suspend fun generateNewAuthTokens(address: String, cardId: String): Either = - either { - val challenge = visaAuthRemoteDataSource - .getCustomerWalletAuthChallenge(address) - .mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") } - .bind() + override suspend fun produceInitialCredentials(cardId: String): Either { + val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId) - val signed = tangemSdkManager.visaCustomerWalletApprove( - VisaDataForApprove( - customerWalletCardId = cardId, - targetAddress = address, - dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge), - ), - ).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind() - - visaAuthRemoteDataSource.getTokenWithCustomerWallet( - sessionId = challenge.session.sessionId, - signature = signed.signature, - nonce = signed.dataToSign.hashToSign, - ) - .mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") } - .bind() + return when (initialCredentials) { + is CompletionResult.Failure<*> -> Either.Left(initialCredentials.error) + is CompletionResult.Success -> Either.Right(initialCredentials.data) } + } override suspend fun refreshAuthTokens(refreshToken: String): Either = either { visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens( @@ -47,9 +31,4 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( .mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") } .bind() } -} - -private fun CompletionResult.toEither(map: (Throwable) -> Throwable) = when (this) { - is CompletionResult.Success -> Either.Right(data) - is CompletionResult.Failure -> Either.Left(map(error)) } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index c6222c62c7..9952a6cbeb 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -1,16 +1,19 @@ package com.tangem.data.pay.di -import com.tangem.data.pay.DefaultDataForReceiveFactory -import com.tangem.data.pay.repository.DefaultCardDetailsRepository +import com.tangem.data.pay.DefaultTangemPayTopUpDataFactory +import com.tangem.data.pay.repository.DefaultTangemPayCardDetailsRepository 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.usecase.DefaultGetTangemPayCurrencyStatusUseCase +import com.tangem.domain.pay.TangemPayTopUpDataFactory +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase +import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository import dagger.Binds import dagger.Module @@ -37,11 +40,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 @@ -52,6 +61,14 @@ internal interface TangemPayDataModule { return TangemPayMainScreenCustomerInfoUseCase(repository = repository) } + @Provides + @Singleton + fun provideProduceTangemPayInitialDataUseCase( + repository: OnboardingRepository, + ): ProduceTangemPayInitialDataUseCase { + return ProduceTangemPayInitialDataUseCase(repository = repository) + } + @Provides @Singleton fun provideTangemPayIssueOrderUseCase(repository: OnboardingRepository): TangemPayIssueOrderUseCase { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt new file mode 100644 index 0000000000..cdbe85d100 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt @@ -0,0 +1,6 @@ +package com.tangem.data.pay.entity + +internal data class EncryptedData( + val encryptedBase64: String, + val ivBase64: String, +) \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt 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/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index dbe40d9c20..05c3965563 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -2,17 +2,22 @@ package com.tangem.data.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.data.pay.util.TangemPayWalletsManager +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest import com.tangem.datasource.api.pay.models.request.OrderRequest import com.tangem.datasource.api.pay.models.response.CustomerMeResponse +import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.CustomerInfo.ProductInstance 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.model.TangemPayCardFrozenState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import javax.inject.Inject @@ -21,22 +26,51 @@ 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 tangemPayWalletsManager: TangemPayWalletsManager, + private val cardFrozenStateStore: TangemPayCardFrozenStateStore, ) : OnboardingRepository { override suspend fun validateDeeplink(link: String): Either { return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.request { - tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)) - }.result + val result = tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)) + .getOrThrow() + .result result?.status == VALID_STATUS } } + override suspend fun isTangemPayInitialDataProduced(): Boolean { + val walletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId + val customerWalletAddress = tangemPayStorage.getCustomerWalletAddress(walletId) ?: return false + tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return false + + return true + } + + override suspend fun produceInitialData() { + val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay() + val initialCredentials = authDataSource.produceInitialCredentials(cardId = wallet.cardId) + .fold( + ifLeft = { error -> error("Can not produce initial data: ${error.message}") }, + ifRight = { it }, + ) + tangemPayStorage.storeCustomerWalletAddress( + userWalletId = wallet.walletId, + customerWalletAddress = initialCredentials.customerWalletAddress, + ) + tangemPayStorage.storeAuthTokens( + customerWalletAddress = initialCredentials.customerWalletAddress, + tokens = initialCredentials.authTokens, + ) + } + override suspend fun getCustomerInfo(): Either { return requestHelper.runWithErrorLogs(TAG) { val result = requestHelper.request { authHeader -> @@ -84,7 +118,7 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun createOrder(): Either = withContext(dispatcherProvider.io) { requestHelper.runWithErrorLogs(TAG) { val walletAddress = requestHelper.getCustomerWalletAddress() - val result = requestHelper.requestWithPersistedToken { authHeader -> + val result = requestHelper.request { authHeader -> tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress)) }.result ?: error("Create order result is null") @@ -92,7 +126,7 @@ internal class DefaultOnboardingRepository @Inject constructor( } } - private fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo { + private suspend fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo { val card = response?.card val balance = response?.balance val paymentAccount = response?.paymentAccount @@ -107,8 +141,25 @@ internal class DefaultOnboardingRepository @Inject constructor( } else { null } + val productInstance = response?.productInstance?.let { instance -> + cardFrozenStateStore.store( + key = instance.cardId, + value = when (instance.status) { + CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen + else -> TangemPayCardFrozenState.Frozen + }, + ) + ProductInstance( + id = instance.id, + cardId = instance.cardId, + status = when (instance.status) { + CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE + else -> ProductInstance.Status.INACTIVE + }, + ) + } return CustomerInfo( - productInstance = response?.productInstance?.let { ProductInstance(id = it.id, status = it.status) }, + productInstance = productInstance, isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS, cardInfo = cardInfo, ) @@ -128,7 +179,7 @@ internal class DefaultOnboardingRepository @Inject constructor( } private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo { - val result = requestHelper.requestWithPersistedToken { authHeader -> + val result = requestHelper.request { authHeader -> tangemPayApi.getCustomerMe(authHeader) }.result return getCustomerInfo(result) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt new file mode 100644 index 0000000000..1d9a850f24 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -0,0 +1,186 @@ +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.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(): 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 (secretKeyBytes, 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, + 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(pin: String): Either { + return requestHelper.runWithErrorLogs(TAG) { + val publicKeyBase64 = getPublicKeyBase64() + val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64) + val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes) + secretKeyBytes.fill(0) + + val status = requestHelper.request { authHeader -> + tangemPayApi.setPin( + authHeader = authHeader, + body = SetPinRequest( + sessionId = sessionId, + pin = encryptedData.encryptedBase64, + iv = encryptedData.ivBase64, + ), + ) + }.result?.result ?: error("Cannot set pin code") + when (status) { + SetPinResult.SUCCESS.name -> SetPinResult.SUCCESS + SetPinResult.PIN_TOO_WEAK.name -> SetPinResult.PIN_TOO_WEAK + SetPinResult.DECRYPTION_ERROR.name -> SetPinResult.DECRYPTION_ERROR + else -> SetPinResult.UNKNOWN_ERROR + } + } + } + + override suspend fun isAddToWalletDone(): Either { + return requestHelper.runWithErrorLogs(TAG) { + storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress()) + } + } + + override suspend fun setAddToWalletAsDone(): Either { + return requestHelper.runWithErrorLogs(TAG) { + storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(), isDone = true) + } + } + + override suspend fun freezeCard(cardId: String): Either { + cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) + return requestHelper.makeSafeRequest { + 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(cardId: String): Either { + cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) + return requestHelper.makeSafeRequest { + 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..b7c1bac9d4 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt @@ -1,8 +1,10 @@ package com.tangem.data.pay.repository +import com.squareup.moshi.Moshi import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext @@ -26,8 +28,11 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( private val cacheRegistry: CacheRegistry, private val txHistoryItemsStore: TangemPayTxHistoryItemsStore, private val dispatchers: CoroutineDispatcherProvider, + @NetworkMoshi private val moshi: Moshi, ) : TangemPayTxHistoryRepository { + private val txHistoryItemConverter by lazy { TangemPayTxHistoryItemConverter(moshi) } + override fun getTxHistoryBatchFlow( batchSize: Int, context: TangemPayTxHistoryListBatchingContext, @@ -84,7 +89,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( val result = requestPerformer.request { authHeader -> visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor) }.result - val items = TangemPayTxHistoryItemConverter.convertList(result.transactions).filterNotNull() + val items = txHistoryItemConverter.convertList(result.transactions).filterNotNull() txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items) }.onLeft { error(it.toString()) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt index c52ec65d24..32af571dc3 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,10 +1,9 @@ package com.tangem.data.pay.repository import arrow.core.Either +import arrow.core.Either.Companion.catch import com.squareup.moshi.Moshi -import com.tangem.blockchain.common.Blockchain import com.tangem.core.error.UniversalError -import com.tangem.data.common.network.NetworkFactory import com.tangem.data.pay.util.TangemPayErrorConverter import com.tangem.data.pay.util.TangemPayWalletsManager import com.tangem.datasource.api.common.response.ApiResponse @@ -13,28 +12,26 @@ import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.VisaAuthTokens -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.derivations.derivationStyleProvider +import com.tangem.domain.visa.model.getAuthHeader import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") internal class TangemPayRequestPerformer @Inject constructor( @NetworkMoshi moshi: Moshi, 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 @@ -58,6 +55,16 @@ internal class TangemPayRequestPerformer @Inject constructor( } } + suspend fun makeSafeRequest( + requestBlock: suspend (header: String) -> ApiResponse, + ): Either { + return catch { request(requestBlock) } + .mapLeft { exception -> + Timber.tag("TangemPayRequestPerformer").e(exception) + errorConverter.convert(exception) + } + } + suspend fun request(requestBlock: suspend (header: String) -> ApiResponse): T = withContext(dispatchers.io) { performRequest( @@ -67,21 +74,14 @@ internal class TangemPayRequestPerformer @Inject constructor( ) } - suspend fun requestWithPersistedToken(requestBlock: suspend (header: String) -> ApiResponse): T = - withContext(dispatchers.io) { - performRequest( - requestBlock = requestBlock, - getTokens = { getAccessTokensIfSaved() ?: error("Cannot get saved access tokens") }, - refreshTokens = ::refreshAuthTokens, - ) - } - private suspend fun performRequest( requestBlock: suspend (header: String) -> ApiResponse, getTokens: (suspend () -> VisaAuthTokens), refreshTokens: (suspend () -> VisaAuthTokens)? = null, ): T = runCatching { - requestBlock("Bearer ${getTokens().accessToken}").getOrThrow() + val tokens = getTokens() + val header = tokens.getAuthHeader() + requestBlock(header).getOrThrow() }.getOrElse { error -> val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) { @@ -116,52 +116,34 @@ internal class TangemPayRequestPerformer @Inject constructor( return result } - suspend fun getCustomerWalletAddress(): String = customerWalletAddress ?: fetchAuthInputData().address + suspend fun getCustomerWalletAddress(): String { + val existingAddress = customerWalletAddress.value + if (existingAddress != null) { + return existingAddress + } + val storedAddress = tangemPayStorage.getCustomerWalletAddress( + userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId, + ) ?: error("Can not find customer address") + + customerWalletAddress.value = storedAddress + return storedAddress + } private suspend fun getAccessTokens(): VisaAuthTokens { - return getAccessTokensIfSaved() ?: fetchTokens() - } - - private suspend fun getAccessTokensIfSaved(): VisaAuthTokens? { - return tangemPayStorage.getAuthTokens(getCustomerWalletAddress()) - } - - private suspend fun fetchAuthInputData(): AuthInputData { - val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay() - - val network = networkFactory.create( - blockchain = Blockchain.Polygon, - extraDerivationPath = null, - derivationStyleProvider = wallet.derivationStyleProvider, - canHandleTokens = true, - ) ?: error("Cannot create network") - - val address = walletManagersFacade.getDefaultAddress(wallet.walletId, network) - ?: error("Cannot get polygon address") - - customerWalletAddress = address - - return AuthInputData(address, wallet.cardId) - } - - private suspend fun fetchTokens(): VisaAuthTokens { - val inputData = fetchAuthInputData() - val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId) - .getOrNull() ?: error("Cannot fetch tokens") - tangemPayStorage.storeAuthTokens(inputData.address, tokens) + val walletAddress = getCustomerWalletAddress() + val tokens = tangemPayStorage.getAuthTokens(walletAddress) ?: error("Auth tokens are not stored") return tokens } private suspend fun refreshAuthTokens(): VisaAuthTokens { val customerWalletAddress = getCustomerWalletAddress() val refreshToken = getAccessTokens().refreshToken.value - val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull() ?: error("Cannot refresh tokens") + val tokens = authDataSource.refreshAuthTokens(refreshToken) + .fold( + ifLeft = { error -> error("Cannot refresh tokens: ${error.message}") }, + ifRight = { it }, + ) tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens) return tokens } -} - -internal data class AuthInputData( - val address: String, - val cardId: String, -) \ No newline at end of file +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultGetTangemPayCurrencyStatusUseCase.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultGetTangemPayCurrencyStatusUseCase.kt new file mode 100644 index 0000000000..53ca506151 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultGetTangemPayCurrencyStatusUseCase.kt @@ -0,0 +1,62 @@ +package com.tangem.data.pay.usecase + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase +import java.math.BigDecimal +import javax.inject.Inject + +internal class DefaultGetTangemPayCurrencyStatusUseCase @Inject constructor( + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, + private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher, +) : GetTangemPayCurrencyStatusUseCase { + + override suspend fun invoke( + currency: CryptoCurrency, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + depositAddress: String, + ): CryptoCurrencyStatus? { + val rawCurrencyId = currency.id.rawCurrencyId ?: return null + val quoteStatus = singleQuoteStatusSupplier.getSyncOrNull( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId), + )?.value + val quoteStatusData: QuoteStatus.Data = when (quoteStatus) { + is QuoteStatus.Data -> quoteStatus + else -> { + singleQuoteStatusFetcher.invoke( + params = SingleQuoteStatusFetcher.Params(rawCurrencyId = rawCurrencyId, appCurrencyId = null), + ) + singleQuoteStatusSupplier.getSyncOrNull( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId), + )?.value as? QuoteStatus.Data + } + } ?: return null + + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoAmount, + fiatRate = quoteStatusData.fiatRate, + priceChange = quoteStatusData.priceChange, + fiatAmount = fiatAmount, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + yieldBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt index 24b60d6ea8..4fa2681263 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt @@ -1,11 +1,10 @@ package com.tangem.data.pay.util import android.util.Base64 -import com.tangem.utils.extensions.hexToBytes import java.nio.charset.StandardCharsets import java.security.KeyFactory import java.security.SecureRandom -import com.tangem.common.extensions.toHexString +import com.tangem.data.pay.entity.EncryptedData import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import java.security.spec.X509EncodedKeySpec @@ -21,26 +20,38 @@ private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding" private const val AES_TRANSFORMATION = "AES/GCM/NoPadding" private const val AES_ALGORITHM = "AES" private const val RSA_ALGORITHM = "RSA" +private const val IV_LENGTH_BYTES = 16 +private const val PIN_LENGTH = 4 +private const val PIN_LENGTH_BYTES = 8 +private const val PIN_BLOCK_ISO_9564_FORMAT_PREFIX = '2' +private const val PIN_BLOCK_FILL_CHAR = 'F' internal class RainCryptoUtil @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, ) { - suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair = + suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair = withContext(dispatchers.default) { - val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString() - val sessionId = generateSessionId(publicKeyBase64, secretKeyHex) - secretKeyHex to sessionId + val secretKeyBytes = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) } + val sessionId = generateSessionId(publicKeyBase64, secretKeyBytes) + secretKeyBytes to sessionId } - suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String = + suspend fun encryptPin(pin: String, secretKeyBytes: ByteArray): EncryptedData = withContext(dispatchers.default) { + val bytes = pinBlockByteArray(pin) + try { + encryptSecret(bytes, secretKeyBytes) + } finally { + bytes.clear() + } + } + + suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyBytes: ByteArray): String = withContext(dispatchers.default) { val cipherTextBytes = Base64.decode(base64Secret, Base64.NO_WRAP) if (cipherTextBytes.size < TAG_LENGTH_BYTES) error("Cipher text too short") val initializationVectorBytes = Base64.decode(base64Iv, Base64.NO_WRAP) - val secretKeyBytes = secretKeyHex.hexToBytes() - val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM) val gcmParameterSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, initializationVectorBytes) @@ -51,8 +62,25 @@ internal class RainCryptoUtil @Inject constructor( plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") } } - private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String { - val secretKeyBytes = secretKeyHex.hexToBytes() + private fun encryptSecret(bytes: ByteArray, secretKeyBytes: ByteArray): EncryptedData { + val iv = ByteArray(IV_LENGTH_BYTES).also { SecureRandom().nextBytes(it) } + + val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM) + val gcmSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, iv) + + val cipher = Cipher.getInstance(AES_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, aesSecretKey, gcmSpec) + + val ciphertext = cipher.doFinal(bytes) + bytes.clear() + + return EncryptedData( + encryptedBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP), + ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP), + ) + } + + private fun generateSessionId(publicKeyBase64: String, secretKeyBytes: ByteArray): String { val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP) val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes) val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec) @@ -66,4 +94,26 @@ internal class RainCryptoUtil @Inject constructor( return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP) } + + /** + * Formats PIN into a PIN block using schema: [Prefix][Length][PIN][fill with F]. + * Example: 246784FFFFFFFFFF for PIN 6784. + */ + private fun pinBlockByteArray(pin: String): ByteArray { + require(pin.length == PIN_LENGTH) { "PIN length must be $PIN_LENGTH" } + require(pin.all { it.isDigit() }) { "PIN must contain digits only" } + + val pinBlockHexLength = PIN_LENGTH_BYTES * 2 + val hex = buildString(pinBlockHexLength) { + append(PIN_BLOCK_ISO_9564_FORMAT_PREFIX) + append(PIN_LENGTH.toString()) + append(pin) + while (length < pinBlockHexLength) append(PIN_BLOCK_FILL_CHAR) + } + return hex.toByteArray(StandardCharsets.UTF_8) + } + + private fun ByteArray.clear() { + for (i in indices) this[i] = 0 + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt index d5f0ebf004..7cf0044f76 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,17 +1,16 @@ 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) { val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode return runCatching { 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..fa109771d4 --- /dev/null +++ b/data/wallets/detekt-baseline-debug.xml @@ -0,0 +1,38 @@ + + + + + BooleanPropertyNaming:DefaultWalletsRepository.kt$DefaultWalletsRepository$val legacySaveWalletsInTheApp = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.SAVE_USER_WALLETS_KEY, ) + BooleanPropertyNaming:DefaultWalletsRepository.kt$DefaultWalletsRepository$val legacyShouldSaveAccessCode = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, ) + BooleanPropertyNaming:DefaultWalletsRepository.kt$DefaultWalletsRepository$val requireAccessCode = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, ) + BooleanPropertyNaming:DefaultWalletsRepository.kt$DefaultWalletsRepository$val useBiometricAuthentication = appPreferencesStore.getSyncOrNull( key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY, ) + 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:DefaultWalletsRepository.kt$DefaultWalletsRepository${ if (it is HttpException && it.code == HttpException.Code.NOT_FOUND) { Status.NOTIFIED } else { Status.NOT_NEEDED } } + MultilineLambdaItParameter:DefaultWalletsRepository.kt$DefaultWalletsRepository${ it.getOrDefault( key = userWalletId, defaultValue = SeedPhraseNotificationsStatus.NOT_NEEDED, ) } + MultilineLambdaItParameter:DefaultWalletsRepository.kt$DefaultWalletsRepository${ it.setObjectMap( key = PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY, value = it.getObjectMap<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY) .plus(userWalletId.stringValue to isEnabled), ) } + MultilineLambdaItParameter:DefaultWalletsRepository.kt$DefaultWalletsRepository${ it.setObjectMap( key = PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY, value = it.getObjectMap<Boolean>(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) .plus(userWalletId.stringValue to false), ) } + MultilineLambdaItParameter:DefaultWalletsRepository.kt$DefaultWalletsRepository${ it.setObjectMap( key = PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY, value = it.getObjectMap<Boolean>(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) .plus(userWalletId.stringValue to true), ) } + MultilineLambdaItParameter:DefaultWalletsRepository.kt$DefaultWalletsRepository${ it.toMutableMap().apply { this[id] = value } } + MultilineLambdaItParameter:DefaultWalletsRepository.kt$DefaultWalletsRepository${ val added = it[PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY].orEmpty() it[PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY] = added + userWalletId.stringValue } + MultilineLambdaItParameter:TangemHotWalletSigner.kt$TangemHotWalletSigner${ Timber.e(it) return if (it is TangemSdkError) { CompletionResult.Failure(it) } else { CompletionResult.Failure(TangemSdkError.ExceptionError(it)) } } + MultilineLambdaItParameter:WalletIdBodyConverter.kt$WalletIdBodyConverter${ CardInfoBody( cardId = it.key, cardPublicKey = it.value, ) } + NamedArguments:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository$toState(id, count, deadline, boot) + NamedArguments:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository$toState(id, it.attempts, it.deadline, it.bootCount) + NoNameShadowing:DefaultWalletsRepository.kt$DefaultWalletsRepository$error + NoNameShadowing:DefaultWalletsRepository.kt$DefaultWalletsRepository${ UserWalletId(it.key) } + 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..6824412ac3 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -10,20 +10,14 @@ import com.tangem.datasource.api.common.response.ApiResponseError.HttpException import com.tangem.datasource.api.common.response.fold import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.MarkUserWalletWasCreatedBody -import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody -import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO +import com.tangem.datasource.api.tangemTech.models.* import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status -import com.tangem.datasource.api.tangemTech.models.WalletBody +import com.tangem.datasource.api.tangemTech.models.WalletIdBody.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 +31,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 +45,7 @@ internal class DefaultWalletsRepository( ) : WalletsRepository { private val upgradeWalletNotificationDisabled: MutableStateFlow> = - MutableStateFlow(mutableSetOf()) + MutableStateFlow(mutableSetOf()) override suspend fun shouldSaveUserWalletsSync(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) @@ -260,6 +253,23 @@ internal class DefaultWalletsRepository( } } + override suspend fun createWallet(userWalletId: UserWalletId) { + withContext(dispatchers.io) { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + tangemTechApi.createWallet( + body = WalletIdBody( + walletId = userWalletId.stringValue, + name = userWallet.name, + walletType = when (userWallet) { + is UserWallet.Cold -> WalletType.COLD + is UserWallet.Hot -> WalletType.HOT + }, + ), + ) + } + } + override suspend fun rejectSeedPhraseSecondNotification(userWalletId: UserWalletId) { runCatching(dispatchers.io) { tangemTechApi.updateSeedPhraseSecondNotificationStatus( @@ -381,10 +391,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() + }, ) } 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..8bd49ad41c --- /dev/null +++ b/detekt_baseline_report.txt @@ -0,0 +1,120 @@ +========================================== +Detekt Baseline Updater & Issue Counter +========================================== +Date: 2025-11-20 16:34:32 + +Updating detekt baseline for debug variant... + + +Baseline updated successfully! + +========================================== +Counting issues in baseline files... +========================================== + +Summary: + Total Issues: 1744 + Modules with Issues: 90 + Average Issues per Module: 19 + +Progress: + Fixed: 58 out of 1802 (3%) + Remaining: 1744 + +========================================== +All Modules with Issues (sorted by count) +========================================== + +Module Issues +──────────────────────────────────────────────────────────────── +features/wallet/impl 170 +features/markets/impl 155 +features/onboarding-v2/impl 137 +features/onramp/impl 89 +features/send-v2/impl 80 +features/swap/impl 73 +features/hot-wallet/impl 61 +features/staking/impl 56 +data/wallet-connect 55 +features/swap-v2/impl 53 +features/walletconnect/impl 51 +features/tokendetails/impl 50 +features/manage-tokens/impl 45 +domain/wallets 39 +features/nft/impl 36 +data/wallets 32 +features/tester/impl 31 +features/yield-supply/impl 28 +domain/tokens 28 +features/swap/domain 27 +core/ui 27 +common/ui 26 +data/visa 23 +features/tangempay/details/impl 22 +data/nft 20 +features/swap/data 15 +data/swap 13 +domain/account/status 12 +features/token-recieve/impl 11 +features/qr-scanning/impl 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/staking 9 +data/networks 9 +features/welcome/impl 8 +features/home/impl 8 +domain/transaction 8 +data/account 8 +libs/tangem-sdk-api 7 +data/txhistory 7 +data/tokens 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 +features/swap/domain/models 2 +features/stories/impl 2 +features/create-wallet-selection/impl 2 +domain/yield-supply 2 +domain/swap 2 +domain/nft 2 +data/express 2 +core/ab-tests 2 +common/test 2 +features/referral/data 1 +features/manage-tokens/api 1 +features/kyc/impl 1 +features/hot-wallet/api 1 +features/disclaimer/impl 1 +domain/wallet-connect 1 +domain/visa 1 +data/settings 1 +data/quotes 1 +data/hot-wallet 1 +data/balance-hiding 1 +common/ui-charts 1 +──────────────────────────────────────────────────────────────── \ 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..c817094f5a 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt @@ -1,5 +1,7 @@ package com.tangem.domain.account.models +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -13,23 +15,27 @@ import kotlinx.serialization.Serializable * @property accountStatuses a set of account statuses associated with the user wallet * @property totalAccounts the total number of accounts (including archived ones) * @property totalFiatBalance the total fiat balance across all accounts + * @property sortType the sorting type applied to the accounts + * @property groupType the grouping type applied to the accounts * [REDACTED_AUTHOR] */ @Serializable data class AccountStatusList( val userWalletId: UserWalletId, - val accountStatuses: Set, + val accountStatuses: List, val totalAccounts: Int, val totalFiatBalance: TotalFiatBalance, + val sortType: TokensSortType, + val groupType: TokensGroupType, ) { - val mainAccount: AccountStatus + val mainAccount: AccountStatus.CryptoPortfolio get() = accountStatuses.first { accountStatus -> when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.account.isMainAccount } - } + } as AccountStatus.CryptoPortfolio fun flattenCurrencies(): List = accountStatuses .map { accountStatus -> accountStatus.flattenCurrencies() } diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountProducer.kt new file mode 100644 index 0000000000..541ce35936 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountProducer.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId + +/** + * Produces a flow of [Account.CryptoPortfolio] for a single account identified by [Params.accountId]. + * The flow emits updates whenever the account's portfolio changes. + */ +interface SingleAccountProducer : FlowProducer { + + data class Params(val accountId: AccountId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt index e910bf695e..8967491701 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt @@ -5,6 +5,7 @@ import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow @@ -61,6 +62,13 @@ interface AccountsCRUDRepository { */ suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) + /** + * Saves a list of accounts to the repository + * + * @param accountList the list of accounts to be saved. + */ + suspend fun saveAccountsLocally(accountList: AccountList) + /** * Saves a list of accounts to the repository * @@ -79,18 +87,26 @@ interface AccountsCRUDRepository { suspend fun syncTokens(userWalletId: UserWalletId) /** - * Retrieves the total count of accounts associated with a specific user wallet including archived accounts + * Retrieves the total count of active accounts associated with a specific user wallet excluding archived accounts * * @param userWalletId the unique identifier of the user wallet */ suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option /** - * Provides a flow of the total count of accounts associated with a specific user wallet including archived accounts + * Retrieves the total count of active accounts associated with a specific user wallet excluding archived accounts * * @param userWalletId the unique identifier of the user wallet */ - fun getTotalAccountsCount(userWalletId: UserWalletId): Flow> + suspend fun getTotalActiveAccountsCountSync(userWalletId: UserWalletId): Option + + /** + * Provides a flow of the total count of active accounts associated with a specific user wallet excluding + * archived accounts + * + * @param userWalletId the unique identifier of the user wallet + */ + fun getTotalActiveAccountsCount(userWalletId: UserWalletId): Flow> /** * Retrieves a user wallet by its unique identifier @@ -105,4 +121,12 @@ interface AccountsCRUDRepository { /** Synchronously retrieves all user wallets */ fun getUserWalletsSync(): List + + /** Checks if the provided account name is the default name within the given account list + * + * @param accountList the list of accounts to check against + * @param accountName the account name to be checked + * @throws IllegalArgumentException if the account name matches the default name + */ + fun checkDefaultAccountName(accountList: AccountList, accountName: AccountName) } \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/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..f70ff26efa 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt @@ -2,10 +2,7 @@ package com.tangem.domain.account.usecase import arrow.core.Either import arrow.core.getOrElse -import arrow.core.raise.Raise -import arrow.core.raise.catch -import arrow.core.raise.either -import arrow.core.raise.ensure +import arrow.core.raise.* import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.account.Account @@ -50,8 +47,12 @@ class UpdateCryptoPortfolioUseCase( .setName(name = accountName) .setIcon(icon = icon) - val updatedAccounts = (accountList + updatedAccount).getOrElse { - raise(Error.AccountListRequirementsNotMet(it)) + val updatedAccounts = withError({ Error.AccountListRequirementsNotMet(it) }) { + if (accountName != null) { + checkDefaultName(accountList, accountName) + } + + (accountList + updatedAccount).bind() } saveAccounts(updatedAccounts) @@ -88,6 +89,12 @@ class UpdateCryptoPortfolioUseCase( return if (icon != null) this.copy(icon = icon) else this } + private fun Raise.checkDefaultName(accountList: AccountList, accountName: AccountName) { + withError({ AccountList.Error.DuplicateAccountNames }) { + Either.Companion.catch { crudRepository.checkDefaultAccountName(accountList, accountName) }.bind() + } + } + /** * Represents possible errors that can occur during the update operation */ diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt index 60eea11794..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 index b23bd9bfe0..461bd17267 100644 --- 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 @@ -8,10 +8,10 @@ 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 com.tangem.test.mock.MockAccounts.createAccount import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -32,7 +32,7 @@ class ArchiveCryptoPortfolioUseCaseTest { @Test fun `invoke should archive existing crypto portfolio account`() = runTest { // Arrange - val account = createAccount(userWalletId) + val account = createAccount(derivationIndex = 1) val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! val accountId = account.accountId @@ -122,7 +122,7 @@ class ArchiveCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange - val account = createAccount(userWalletId) + val account = createAccount(derivationIndex = 1) val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! val accountId = account.accountId 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/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..ae69207870 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { api(projects.domain.account) api(projects.domain.core) api(projects.domain.common) + api(projects.domain.express) api(projects.domain.quotes) api(projects.domain.models) api(projects.domain.networks) @@ -40,11 +41,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) } \ 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..5b524e1585 --- /dev/null +++ b/domain/account/status/detekt-baseline-debug.xml @@ -0,0 +1,18 @@ + + + + + ExplicitCollectionElementAccessMethod:ManageCryptoCurrenciesUseCase.kt$ManageCryptoCurrenciesUseCase$mutableMap.put(id, currency) + 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..caf0fb6122 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( + fun provideManageCryptoCurrenciesUseCase( singleAccountListSupplier: SingleAccountListSupplier, accountsCRUDRepository: AccountsCRUDRepository, currenciesRepository: CurrenciesRepository, derivationsRepository: DerivationsRepository, - multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, stakingIdFactory: StakingIdFactory, networksCleaner: NetworksCleaner, stakingCleaner: StakingCleaner, + expressServiceFetcher: ExpressServiceFetcher, dispatchers: CoroutineDispatcherProvider, - ): SaveCryptoCurrenciesUseCase { - return SaveCryptoCurrenciesUseCase( + ): ManageCryptoCurrenciesUseCase { + return ManageCryptoCurrenciesUseCase( singleAccountListSupplier = singleAccountListSupplier, accountsCRUDRepository = accountsCRUDRepository, currenciesRepository = currenciesRepository, derivationsRepository = derivationsRepository, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, + stakingIdFactory = stakingIdFactory, + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + expressServiceFetcher = expressServiceFetcher, + parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideCryptoCurrencyBalanceFetcher( + accountsCRUDRepository: AccountsCRUDRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + stakingIdFactory: StakingIdFactory, + dispatchers: CoroutineDispatcherProvider, + ): CryptoCurrencyBalanceFetcher { + return CryptoCurrencyBalanceFetcher( + accountsCRUDRepository = accountsCRUDRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiYieldBalanceFetcher = multiYieldBalanceFetcher, stakingIdFactory = stakingIdFactory, - networksCleaner = networksCleaner, - stakingCleaner = stakingCleaner, + 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..8d8683c595 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt @@ -4,9 +4,9 @@ import arrow.core.Option import arrow.core.none import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.core.utils.lceContent import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokensGroupType @@ -31,6 +31,7 @@ import java.math.BigDecimal * Produces a flow of [AccountStatusList] for a single user wallet. * * @property params Parameters containing the user wallet ID. + * @property accountsCRUDRepository Repository for accessing account data. * @property singleAccountListSupplier Supplier to get the list of accounts for the user wallet. * @property cryptoCurrencyStatusesFlowFactory Factory to create flows of cryptocurrency statuses. * @property dispatchers Coroutine dispatcher provider for managing threading. @@ -40,7 +41,7 @@ import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor( @Assisted private val params: SingleAccountStatusListProducer.Params, - private val userWalletsListRepository: UserWalletsListRepository, + private val accountsCRUDRepository: AccountsCRUDRepository, private val singleAccountListSupplier: SingleAccountListSupplier, private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory, private val dispatchers: CoroutineDispatcherProvider, @@ -54,36 +55,38 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ) return accountListFlow.flatMapLatest { accountList -> - val accountStatusFlows = accountList.accounts - .filterIsInstance() - .map { account -> - if (account.cryptoCurrencies.isEmpty()) { - createEmptyAccountStatusFlow(account) - } else { - val userWallet = userWalletsListRepository.userWalletsSync().first { - it.walletId == params.userWalletId - } + val accountStatusFlows = accountList.accounts.mapNotNull { account -> + if (account !is Account.CryptoPortfolio) return@mapNotNull null - getAccountStatusFlow( - userWallet = userWallet, - account = account, - groupType = accountList.groupType, - sortType = accountList.sortType, - ) - } + if (account.cryptoCurrencies.isEmpty()) { + createEmptyAccountStatusFlow(account) + } else { + val userWallet = accountsCRUDRepository.getUserWallet(userWalletId = params.userWalletId) + + getAccountStatusFlow( + userWallet = userWallet, + account = account, + groupType = accountList.groupType, + sortType = accountList.sortType, + ) } + .distinctUntilChanged() + } combine(accountStatusFlows) { accountStatuses -> val balances = accountStatuses.map { it.tokenList.totalFiatBalance } AccountStatusList( userWalletId = accountList.userWalletId, - accountStatuses = accountStatuses.toSet(), + accountStatuses = accountStatuses.toList(), totalAccounts = accountList.totalAccounts, totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), + sortType = accountList.sortType, + groupType = accountList.groupType, ) } } + .distinctUntilChanged() .flowOn(dispatchers.default) } @@ -108,6 +111,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ): Flow { val statusesFlows = account.cryptoCurrencies.map { currency -> cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = currency) + .distinctUntilChanged() } return combine(statusesFlows) { statuses -> @@ -123,6 +127,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo priceChangeLce = PriceChangeCalculator.calculate(statuses = statusList), ) } + .distinctUntilChanged() } @AssistedFactory diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusProducer.kt new file mode 100644 index 0000000000..86377c035e --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusProducer.kt @@ -0,0 +1,44 @@ +package com.tangem.domain.account.status.producer + +import arrow.core.Option +import arrow.core.none +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.models.account.AccountStatus +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleAccountStatusProducer]. + * + * @param params Parameters required to produce the account status. + * @param singleAccountStatusListSupplier Supplier to fetch the list of account statuses. + * @param dispatchers Coroutine dispatcher provider for managing threading. + */ +internal class DefaultSingleAccountStatusProducer @AssistedInject constructor( + @Assisted val params: SingleAccountStatusProducer.Params, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleAccountStatusProducer { + + override val fallback: Option get() = none() + + override fun produce(): Flow { + return singleAccountStatusListSupplier(userWalletId = params.accountId.userWalletId) + .mapNotNull { accountStatusList -> + accountStatusList.accountStatuses.firstOrNull { + it.account.accountId == params.accountId + } + } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleAccountStatusProducer.Factory { + override fun create(params: SingleAccountStatusProducer.Params): DefaultSingleAccountStatusProducer + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/SingleAccountStatusProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/SingleAccountStatusProducer.kt new file mode 100644 index 0000000000..1a649fedbc --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/SingleAccountStatusProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.account.status.producer + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus + +/** + * Producer that provides a single [AccountStatus] for a specific account identifier. + * +[REDACTED_AUTHOR] + */ +interface SingleAccountStatusProducer : FlowProducer { + + data class Params(val accountId: AccountId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt index 60ed1d5971..b37d457efc 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/MultiAccountStatusListSupplier.kt @@ -3,6 +3,7 @@ package com.tangem.domain.account.status.supplier import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.MultiAccountStatusListProducer import com.tangem.domain.core.flow.FlowCachingSupplier +import kotlinx.coroutines.flow.Flow /** * Supplier that provides a list of [AccountStatusList]s for all user wallets. @@ -12,4 +13,9 @@ import com.tangem.domain.core.flow.FlowCachingSupplier abstract class MultiAccountStatusListSupplier( override val factory: MultiAccountStatusListProducer.Factory, override val keyCreator: (Unit) -> String, -) : FlowCachingSupplier>() \ No newline at end of file +) : FlowCachingSupplier>() { + + operator fun invoke(): Flow> { + return super.invoke(params = Unit) + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusSupplier.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusSupplier.kt new file mode 100644 index 0000000000..f00b6d0a35 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusSupplier.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.account.status.supplier + +import com.tangem.domain.account.status.producer.SingleAccountStatusProducer +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.account.AccountStatus + +/** + * Supplier that provides a single [AccountStatus] for a specific account identifier. + * +[REDACTED_AUTHOR] + */ +abstract class SingleAccountStatusSupplier( + override val factory: SingleAccountStatusProducer.Factory, + override val keyCreator: (SingleAccountStatusProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseV2.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseV2.kt new file mode 100644 index 0000000000..a3a2854c45 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseV2.kt @@ -0,0 +1,203 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.* +import arrow.core.raise.* +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.model.AccountCryptoCurrencies +import com.tangem.domain.core.utils.eitherOn +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListSortingError +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import timber.log.Timber + +private typealias SortingErrorByAccountId = MutableMap + +/** + * Use case to apply token list sorting and grouping preferences for accounts. + * + * @property accountsCRUDRepository Repository for CRUD operations on accounts. + * @property dispatchers Coroutine dispatcher provider for managing threading. + */ +class ApplyTokenListSortingUseCaseV2( + private val accountsCRUDRepository: AccountsCRUDRepository, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Applies the token list sorting and grouping preferences for the given accounts. + * + * @param sortedTokensIdsByAccount A map of accounts to their sorted token IDs. + * @param isGroupedByNetwork Boolean indicating if tokens should be grouped by network. + * @param isSortedByBalance Boolean indicating if tokens should be sorted by balance. + * @return Either a [TokenListSortingError] or Unit on success. + */ + suspend operator fun invoke( + sortedTokensIdsByAccount: AccountCryptoCurrencies, + isGroupedByNetwork: Boolean, + isSortedByBalance: Boolean, + ): Either = eitherOn(dispatchers.default) { + ensure(sortedTokensIdsByAccount.isNotEmpty()) { + raise(TokenListSortingError.TokenListIsEmpty) + } + + val userWalletId = sortedTokensIdsByAccount.keys.first().userWalletId + + val accountList = getAccountList(userWalletId) + + val maybeSortedAccountList = sortAccountList( + accountList = accountList, + sortedTokensIdsByAccount = sortedTokensIdsByAccount, + isSortedByBalance = isSortedByBalance, + isGroupedByNetwork = isGroupedByNetwork, + ) + + val sortedAccountList = maybeSortedAccountList.getOrNull() + val isSortingTypeChanged = accountList.isSortedByBalance() != isSortedByBalance + val isGroupingTypeChanged = accountList.isGroupedByNetwork() != isGroupedByNetwork + + if (sortedAccountList != null || isSortingTypeChanged || isGroupingTypeChanged) { + applySorting(sortedAccountList ?: accountList) + } + + maybeSortedAccountList.toEitherNeg() + .onLeft { errorByAccountId -> + Timber.e( + """ + Unable to sort tokens for accounts: ${ + errorByAccountId.entries.joinToString { "${it.key.value}: ${it.value}" } + } + """.trimIndent(), + ) + + raise(TokenListSortingError.UnableToSortAccounts(errorByAccountId.keys.map { it.value })) + } + } + + private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { + return catchOrDataError { + accountsCRUDRepository.getAccountListSync(userWalletId = userWalletId).getOrElse { + throw IllegalStateException("Account list not found for wallet $userWalletId") + } + } + } + + private fun AccountList.isSortedByBalance(): Boolean = sortType == TokensSortType.BALANCE + + private fun AccountList.isGroupedByNetwork(): Boolean = groupType == TokensGroupType.NETWORK + + private fun Raise.sortAccountList( + accountList: AccountList, + sortedTokensIdsByAccount: AccountCryptoCurrencies, + isSortedByBalance: Boolean, + isGroupedByNetwork: Boolean, + ): Ior { + val errors = mutableMapOf() + + val updatedAccountList = AccountList( + userWalletId = accountList.userWalletId, + accounts = accountList.accounts.sortTokens(sortedTokensIdsByAccount, errors), + totalAccounts = accountList.totalAccounts, + sortType = if (isSortedByBalance) TokensSortType.BALANCE else TokensSortType.NONE, + groupType = if (isGroupedByNetwork) TokensGroupType.NETWORK else TokensGroupType.NONE, + ).getOrElse { + raise(TokenListSortingError.UnableToSortTokenList) + } + + return if (errors.isEmpty()) { + updatedAccountList.rightIor() + } else if (errors.size == sortedTokensIdsByAccount.keys.size) { + errors.leftIor() + } else { + (errors to updatedAccountList).bothIor() + } + } + + private fun List.sortTokens( + sortedTokensIdsByAccount: AccountCryptoCurrencies, + errors: MutableMap, + ): List { + return map { account -> + if (account !is Account.CryptoPortfolio) return@map account + + val sortedCurrencies = sortedTokensIdsByAccount[account] ?: return@map account + + val accountCurrencies = account.cryptoCurrencies.toList() + .sortTokens(sortedCurrencies = sortedCurrencies) + .getOrElse { + errors[account.accountId] = it + return@map account + } + .toSet() + + account.copy(cryptoCurrencies = accountCurrencies) + } + } + + private fun List.sortTokens( + sortedCurrencies: List, + ): Either> = either { + val nonEmptySortedTokensIds = ensureNotNull(sortedCurrencies.toNonEmptySetOrNull()) { + TokenListSortingError.TokenListIsEmpty + } + + val sortedTokens = sortedMapOf() + + distinct().forEach { currency -> + val index = nonEmptySortedTokensIds.indexOfFirst { sortedCurrency -> + sortedCurrency.id == currency.id + } + + if (index >= 0) { + sortedTokens[index] = currency + } else { + raise(TokenListSortingError.UnableToSortTokenList) + } + } + + ensureNotNull(sortedTokens.values.toNonEmptyListOrNull()) { + TokenListSortingError.TokenListIsEmpty + } + } + + private suspend fun Raise.applySorting(accountList: AccountList) { + coroutineScope { + val results = awaitAll( + async { + Either.catch { accountsCRUDRepository.saveAccountsLocally(accountList) } + }, + async { + Either.catch { accountsCRUDRepository.syncTokens(accountList.userWalletId) } + }, + ) + + ensure(results.none { it.isLeft() }) { + val message = results.mapNotNull { it.leftOrNull() }.joinToString() + raise(TokenListSortingError.DataError(IllegalStateException(message))) + } + } + } + + private fun Ior.toEitherNeg(): Either { + return when (this) { + is Ior.Left -> this.value.left() + is Ior.Right -> this.value.right() + is Ior.Both -> this.leftValue.left() + } + } + + private suspend fun Raise.catchOrDataError(block: suspend () -> A): A { + return catch( + block = { block() }, + catch = { raise(TokenListSortingError.DataError(it)) }, + ) + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt index 18abf00e6c..b29d088ba7 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt @@ -9,9 +9,9 @@ import arrow.core.raise.option import arrow.core.toNonEmptyListOrNull import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.model.AccountCryptoCurrency import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.getAddress @@ -29,14 +29,14 @@ private typealias WalletIdWithNetworkId = Pair /** * Use case to retrieve an [AccountCryptoCurrency] based on a provided address. * - * @property userWalletsListRepository Repository to access user wallets. + * @property accountsCRUDRepository Repository to access user wallets. * @property multiNetworkStatusSupplier Supplier to get network status for multiple networks. * @property singleAccountListSupplier Supplier to get account lists for a single wallet. * [REDACTED_AUTHOR] */ class GetAccountCurrencyByAddressUseCase( - private val userWalletsListRepository: UserWalletsListRepository, + private val accountsCRUDRepository: AccountsCRUDRepository, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val singleAccountListSupplier: SingleAccountListSupplier, ) { @@ -65,7 +65,7 @@ class GetAccountCurrencyByAddressUseCase( } private fun OptionRaise.getUserWalletIds(): NonEmptyList { - val userWalletIds = userWalletsListRepository.userWallets.value.orEmpty() + val userWalletIds = accountsCRUDRepository.getUserWalletsSync() .filter(UserWallet::isMultiCurrency) .map(UserWallet::walletId) .toNonEmptyListOrNull() diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt index 730bff6aa4..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 74% rename from domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt rename to domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 144421a34d..ca59ae9ed8 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -5,18 +5,18 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.core.utils.eitherOn +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.utils.NetworksCleaner -import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.wallets.derivations.DerivationsRepository @@ -28,30 +28,31 @@ import timber.log.Timber * Use case for saving crypto currencies to a specific account. * * @property singleAccountListSupplier Supplier to get account details. + * @property accountsCRUDRepository Repository for performing CRUD operations on accounts. * @property currenciesRepository Repository for managing currencies. * @property derivationsRepository Repository for deriving public keys. - * @property multiNetworkStatusFetcher Fetcher for updating network statuses. - * @property multiQuoteStatusFetcher Fetcher for updating quote statuses. - * @property multiYieldBalanceFetcher Fetcher for updating yield balances. + * @property cryptoCurrencyBalanceFetcher Fetcher for updating crypto currency balances. * @property stakingIdFactory Factory for creating staking IDs. * @property networksCleaner Cleaner for removing obsolete network data. * @property stakingCleaner Cleaner for removing obsolete staking data. + * @property expressServiceFetcher Fetcher for updating express service data. + * @property parallelUpdatingScope Coroutine scope for parallel updates. * @property dispatchers Coroutine dispatchers for managing threading. * [REDACTED_AUTHOR] */ @Suppress("LongParameterList") -class SaveCryptoCurrenciesUseCase( +class ManageCryptoCurrenciesUseCase( private val singleAccountListSupplier: SingleAccountListSupplier, private val accountsCRUDRepository: AccountsCRUDRepository, private val currenciesRepository: CurrenciesRepository, private val derivationsRepository: DerivationsRepository, - private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, private val stakingIdFactory: StakingIdFactory, private val networksCleaner: NetworksCleaner, private val stakingCleaner: StakingCleaner, + private val expressServiceFetcher: ExpressServiceFetcher, + private val parallelUpdatingScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -83,12 +84,28 @@ class SaveCryptoCurrenciesUseCase( account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()), ) - derivePublicKeys(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + val isDerivingFailed = derivePublicKeys( + userWalletId = userWalletId, + currencies = modifiedCurrencyList.added, + ).isLeft() - val jobs = refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + + parallelUpdatingScope.launch { + /* + * If only removal of currencies happened, we need to sync tokens. Otherwise, tokens will be synced + * when balances are refreshed for added currencies. + */ + val isOnlyRemoval = modifiedCurrencyList.added.isEmpty() && modifiedCurrencyList.removed.isNotEmpty() + + if (isDerivingFailed || isOnlyRemoval) { + launch { accountsCRUDRepository.syncTokens(userWalletId) } + } + + if (isDerivingFailed) return@launch + + cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed) - - jobs.joinAll() + } } } @@ -114,7 +131,14 @@ class SaveCryptoCurrenciesUseCase( val tokenToAdd = findToken(userWalletId, contractAddress, networkId) - refreshBalances(userWalletId = userWalletId, currencies = listOf(tokenToAdd)).joinAll() + val modifiedCurrencyList = account.cryptoCurrencies.modify(add = listOf(tokenToAdd)) + + saveAccount(account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet())) + + parallelUpdatingScope.launch { + cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = listOf(tokenToAdd)) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) + } tokenToAdd } @@ -131,7 +155,7 @@ class SaveCryptoCurrenciesUseCase( private fun Set.modify( add: List, - remove: List, + remove: List = emptyList(), ): ModifiedCurrencyList { val mutableCurrencies = this.toMutableList() val added = mutableListOf() @@ -164,9 +188,8 @@ class SaveCryptoCurrenciesUseCase( remove.groupByNetwork(valuePredicate = existingCurrenciesById::containsKey) .forEach { (network, currenciesById) -> - val coinTempId = TempID(network) - - if (currenciesById.containsKey(coinTempId)) { + val isCoinBeingRemoved = currenciesById.any { it.value is CryptoCurrency.Coin } + if (isCoinBeingRemoved) { val existingNetworkCurrenciesCount = mutableCurrencies.count { it.network == network } if (existingNetworkCurrenciesCount != currenciesById.size) { @@ -188,14 +211,11 @@ class SaveCryptoCurrenciesUseCase( ) } - private suspend fun Raise.derivePublicKeys( + private suspend fun derivePublicKeys( userWalletId: UserWalletId, currencies: List, - ) { - catch( - block = { derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) }, - catch = ::raise, - ) + ): Either = Either.catch { + derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) } private fun List.groupByNetwork( @@ -234,52 +254,27 @@ class SaveCryptoCurrenciesUseCase( ) } - private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List): List { - if (currencies.isEmpty()) return emptyList() + private suspend fun refreshExpress(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return - return coroutineScope { - listOf( - launch { refreshNetworks(userWalletId = userWalletId, currencies = currencies) }, - launch { refreshYieldBalances(userWalletId = userWalletId, currencies = currencies) }, - launch { refreshQuotes(currencies = currencies) }, - ) + coroutineScope { + launch { + val assetIds = currencies.mapTo(hashSetOf()) { + ExpressAsset.ID( + networkId = it.network.backendId, + contractAddress = (it as? CryptoCurrency.Token)?.contractAddress, + ) + } + + expressServiceFetcher.fetch(userWalletId = userWalletId, assetIds = assetIds) + } } } - private suspend fun refreshNetworks(userWalletId: UserWalletId, currencies: List) { - multiNetworkStatusFetcher( - params = MultiNetworkStatusFetcher.Params( - userWalletId = userWalletId, - networks = currencies.mapTo(hashSetOf(), CryptoCurrency::network), - ), - ) + private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return - accountsCRUDRepository.syncTokens(userWalletId) - } - - private suspend fun refreshYieldBalances(userWalletId: UserWalletId, currencies: List) { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - multiYieldBalanceFetcher( - params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds), - ) - } - - private suspend fun refreshQuotes(currencies: List) { - multiQuoteStatusFetcher( - params = MultiQuoteStatusFetcher.Params( - currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, - appCurrencyId = null, - ), - ) - } - - private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List): List { - if (currencies.isEmpty()) return emptyList() - - return coroutineScope { + coroutineScope { listOf( launch { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, launch { clearStaking(userWalletId = userWalletId, currencies = currencies) }, diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCase.kt similarity index 67% rename from domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt rename to domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCase.kt index 8e52598bb2..d2eb7a9b1c 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCase.kt @@ -1,7 +1,8 @@ -package com.tangem.domain.account.usecase +package com.tangem.domain.account.status.usecase import arrow.core.Either import arrow.core.getOrElse +import arrow.core.merge import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either @@ -9,9 +10,12 @@ import arrow.core.raise.ensure import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.utils.AccountNameIndexer +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWalletId /** @@ -25,6 +29,7 @@ import com.tangem.domain.models.wallet.UserWalletId class RecoverCryptoPortfolioUseCase( private val crudRepository: AccountsCRUDRepository, private val mainAccountTokensMigration: MainAccountTokensMigration, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, ) { /** @@ -43,8 +48,7 @@ class RecoverCryptoPortfolioUseCase( val recoveredAccount = archivedAccount.recover() - val updatedAccountList = (accountList + recoveredAccount) - .getOrElse { raise(Error.AccountListRequirementsNotMet(cause = it)) } + val updatedAccountList = add(accountList, recoveredAccount) saveAccounts(updatedAccountList) @@ -53,6 +57,8 @@ class RecoverCryptoPortfolioUseCase( derivationIndex = recoveredAccount.derivationIndex, ) + refreshBalances(accountId = accountId) + recoveredAccount } @@ -93,6 +99,42 @@ class RecoverCryptoPortfolioUseCase( ) } + private fun Raise.add(accountList: AccountList, recoveredAccount: Account.CryptoPortfolio): AccountList { + val maybeAccountList = accountList + recoveredAccount + + return maybeAccountList.mapLeft { + val recoveredAccountName = recoveredAccount.accountName as? AccountName.Custom + ?: raise(Error.DataOperationFailed(message = "Recovered account should have a custom name")) + + val indexedName = AccountNameIndexer.transform(from = recoveredAccountName.value) + .let(AccountName::invoke).getOrNull() + ?: raise(Error.DataOperationFailed(message = "Failed to generate indexed account name")) + + val renamedAccount = recoveredAccount.copy(accountName = indexedName) + + // recursively try to add the account with the new name + add(accountList = accountList, recoveredAccount = renamedAccount) + } + .merge() + } + + private suspend fun refreshBalances(accountId: AccountId): Either = either { + val currencies = catch( + block = { crudRepository.getAccountSync(accountId = accountId) }, + catch = { raise(Error.DataOperationFailed(cause = it)) }, + ) + .getOrElse { raise(Error.DataOperationFailed(message = "Account not found: $accountId")) } + .cryptoCurrencies + .toList() + + catch( + block = { + cryptoCurrencyBalanceFetcher(userWalletId = accountId.userWalletId, currencies = currencies) + }, + catch = { raise(Error.DataOperationFailed(cause = it)) }, + ) + } + /** * Represents possible errors that can occur during the add operation */ diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/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..a02e8b9108 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,12 @@ 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,6 +20,7 @@ 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.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +38,7 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultSingleAccountStatusListProducerTest { - private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val accountsCRUDRepository: AccountsCRUDRepository = mockk() private val singleAccountListSupplier: SingleAccountListSupplier = mockk() private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk() @@ -49,7 +49,7 @@ class DefaultSingleAccountStatusListProducerTest { private val producer = DefaultSingleAccountStatusListProducer( params = SingleAccountStatusListProducer.Params(userWalletId), - userWalletsListRepository = userWalletsListRepository, + accountsCRUDRepository = accountsCRUDRepository, singleAccountListSupplier = singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory, dispatchers = TestingCoroutineDispatcherProvider(), @@ -57,7 +57,7 @@ class DefaultSingleAccountStatusListProducerTest { @AfterEach fun tearDown() { - clearMocks(singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory) + clearMocks(accountsCRUDRepository, singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory) } @Test @@ -75,7 +75,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -84,6 +84,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual).containsExactly(expected) @@ -110,7 +112,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (first emission) val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -119,6 +121,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual1).containsExactly(expected) @@ -129,7 +133,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (second emission) val expected2 = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = updatedAccountList.mainAccount, tokenList = TokenList.Empty, @@ -138,6 +142,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = updatedAccountList.sortType, + groupType = updatedAccountList.groupType, ) Truth.assertThat(actual2).containsExactly(expected2) @@ -159,7 +165,7 @@ class DefaultSingleAccountStatusListProducerTest { val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Empty, @@ -168,6 +174,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + sortType = accountList.sortType, + groupType = accountList.groupType, ) // Act (first emission) @@ -198,7 +206,7 @@ class DefaultSingleAccountStatusListProducerTest { cryptoCurrencies = cryptoCurrencyFactory.ethereumAndStellar.toSet(), ) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + coEvery { accountsCRUDRepository.getUserWallet(userWalletId = userWalletId) } returns userWallet every { singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) @@ -226,7 +234,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert val expected = AccountStatusList( userWalletId = userWalletId, - accountStatuses = setOf( + accountStatuses = listOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, tokenList = TokenList.Ungrouped( @@ -239,6 +247,8 @@ class DefaultSingleAccountStatusListProducerTest { ), totalAccounts = 1, totalFiatBalance = TotalFiatBalance.Loading, + sortType = accountList.sortType, + groupType = accountList.groupType, ) Truth.assertThat(actual).containsExactly(expected) diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseTest.kt new file mode 100644 index 0000000000..48026d148b --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ApplyTokenListSortingUseCaseTest.kt @@ -0,0 +1,375 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.left +import arrow.core.right +import arrow.core.some +import com.google.common.truth.Truth +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListSortingError +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ApplyTokenListSortingUseCaseTest { + + private val accountsCRUDRepository = mockk(relaxUnitFun = true) + + private val useCase = ApplyTokenListSortingUseCaseV2( + accountsCRUDRepository = accountsCRUDRepository, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId(stringValue = "011") + + @AfterEach + fun tearDown() { + clearMocks(accountsCRUDRepository) + } + + @Test + fun `when tokens are empty then error should be received`() = runTest { + // Act + val actual = useCase( + sortedTokensIdsByAccount = emptyMap(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + + // Assert + val expected = TokenListSortingError.TokenListIsEmpty.left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder(inverse = true) { + accountsCRUDRepository.getAccountListSync(userWalletId = any()) + accountsCRUDRepository.saveAccountsLocally(accountList = any()) + accountsCRUDRepository.syncTokens(userWalletId = any()) + } + } + + @Test + fun `when getAccountListSync throws exception then error should be received`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet()) + + val exception = IllegalStateException("No internet connection") + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } throws exception + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ) + + // Assert + val expected = TokenListSortingError.DataError(exception).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) } + + coVerifyOrder(inverse = true) { + accountsCRUDRepository.saveAccountsLocally(accountList = any()) + accountsCRUDRepository.syncTokens(userWalletId = any()) + } + } + + @Test + fun `when saveAccountsLocally throws exception then error should be received`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet()) + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val exception = IllegalStateException("No internet connection") + coEvery { accountsCRUDRepository.saveAccountsLocally(accountList) } throws exception + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ).leftOrNull() as TokenListSortingError.DataError + + // Assert + val expected = TokenListSortingError.DataError(IllegalStateException(exception.toString())) + Truth.assertThat(actual.cause).isInstanceOf(expected.cause::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.cause.message) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(accountList) + accountsCRUDRepository.syncTokens(userWalletId = userWalletId) + } + } + + @Test + fun `when syncTokens throws exception then error should be received`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet()) + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val exception = IllegalStateException("No internet connection") + coEvery { accountsCRUDRepository.syncTokens(userWalletId) } throws exception + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ).leftOrNull() as TokenListSortingError.DataError + + // Assert + val expected = TokenListSortingError.DataError(IllegalStateException(exception.toString())) + Truth.assertThat(actual.cause).isInstanceOf(expected.cause::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.cause.message) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(accountList) + accountsCRUDRepository.syncTokens(userWalletId = userWalletId) + } + } + + @Test + fun `when apply sorting for sorted and grouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = true, + isSortedByBalance = true, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when apply sorting for unsorted and grouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NETWORK, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = true, + isSortedByBalance = false, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when apply sorting for sorted and ungrouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NONE, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = false, + isSortedByBalance = true, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when apply sorting for unsorted and ungrouped list then correct args should be used`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ) + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokens = setOf(token2, token3, token1) + val updatedAccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = sortedTokens, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + + // Act + val actual = useCase( + sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } + + @Test + fun `when sorted tokens IDs do not contain all tokens IDs then error should be received`() = runTest { + // Arrange + val token1 = mockk(relaxed = true) + val token2 = mockk(relaxed = true) + val token3 = mockk(relaxed = true) + + val customAccount = Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(1).getOrNull()!!), + name = "Account 1", + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = 1, + cryptoCurrencies = setOf(token1, token2, token3), + ) + .getOrNull()!! + + val accountList = (AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = setOf(token1, token2, token3), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + customAccount).getOrNull()!! + + coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some() + + val sortedTokensIdsByAccount: Map> = mapOf( + accountList.mainAccount to listOf(token3, token2, token1), + customAccount to listOf(token2, token1), // missing token3 + ) + + val updatedAccountList = AccountList( + userWalletId = userWalletId, + accounts = listOf( + accountList.mainAccount.copy(cryptoCurrencies = setOf(token3, token2, token1)), + customAccount, // unchanged due to error + ), + totalAccounts = accountList.totalAccounts, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + .getOrNull()!! + + // Act + val actual = useCase( + sortedTokensIdsByAccount = sortedTokensIdsByAccount, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + + // Assert + val expected = TokenListSortingError.UnableToSortAccounts( + accountIds = listOf(customAccount.accountId.value), + ).left() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + accountsCRUDRepository.getAccountListSync(userWalletId) + accountsCRUDRepository.saveAccountsLocally(updatedAccountList) + accountsCRUDRepository.syncTokens(userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt index 34b63f7842..8be24389d9 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,14 +104,14 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() } } @Test fun `returns None if multiNetworkStatusSupplier returns null`() = runTest { // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns null @@ -123,7 +123,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } } @@ -131,7 +131,7 @@ class GetAccountCurrencyByAddressUseCaseTest { @Test fun `returns None if multiNetworkStatusSupplier returns empty list`() = runTest { // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns emptySet() @@ -143,7 +143,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } } @@ -156,7 +156,7 @@ class GetAccountCurrencyByAddressUseCaseTest { value = NetworkStatus.Unreachable(address = null), ) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -168,7 +168,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } } @@ -183,7 +183,7 @@ class GetAccountCurrencyByAddressUseCaseTest { value = NetworkStatus.Unreachable(address = validNetworkAddress), ) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -200,7 +200,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), @@ -220,7 +220,7 @@ class GetAccountCurrencyByAddressUseCaseTest { ) val accountList = AccountList.empty(userWalletId) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -237,7 +237,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertNone(actual) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), @@ -255,7 +255,7 @@ class GetAccountCurrencyByAddressUseCaseTest { ) val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = setOf(currency)) - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet) coEvery { multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) } returns setOf(networkStatus) @@ -273,7 +273,7 @@ class GetAccountCurrencyByAddressUseCaseTest { assertSome(actual, expected) coVerifySequence { - userWalletsListRepository.userWallets + accountsCRUDRepository.getUserWalletsSync() multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) singleAccountListSupplier.getSyncOrNull( params = SingleAccountListProducer.Params(userWalletId = userWalletId), diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt index 7adf3a3264..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/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/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/detekt-baseline-debug.xml b/domain/nft/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d57ec3f083 --- /dev/null +++ b/domain/nft/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + MultilineLambdaItParameter:FilterNFTAvailableNetworksUseCase.kt$FilterNFTAvailableNetworksUseCase${ it.name.contains(searchQuery, ignoreCase = true) || it.currencySymbol.contains(searchQuery, ignoreCase = true) } + UseEmptyCounterpart:NFTAnalyticsEvent.kt$NFTAnalyticsEvent$mapOf() + + diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/WalletNFTCollections.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/WalletNFTCollections.kt new file mode 100644 index 0000000000..f6526e3509 --- /dev/null +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/WalletNFTCollections.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.nft.models + +import com.tangem.domain.models.account.Account + +data class WalletNFTCollections( + val collections: Map>, +) { + val flattenCollections by lazy { collections.values.flatten() } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt index 954646a953..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/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/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/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..c10b932460 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.staking.model.ethpool + +import com.tangem.domain.models.serialization.SerializedBigDecimal + +/** + * P2P.org pooled staking vault information + * Simplified version for vault list display + */ +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/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/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..469d2cb74f --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -0,0 +1,112 @@ +package com.tangem.domain.staking.repositories + +import arrow.core.Either +import com.tangem.domain.staking.model.ethpool.* +import com.tangem.domain.staking.model.stakekit.StakingError + +/** + * P2P staking repository interface + */ +interface P2PEthPoolRepository { + + /** + * 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> +} \ 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/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/detekt-baseline-debug.xml b/domain/swap/detekt-baseline-debug.xml new file mode 100644 index 0000000000..950588cc6a --- /dev/null +++ b/domain/swap/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + CouldBeSequence:GetSwapPairsUseCase.kt$GetSwapPairsUseCase$filter { pair -> filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId } + CouldBeSequence:GetSwapSupportedPairsUseCase.kt$GetSwapSupportedPairsUseCase$filter { pair -> filteringCurrency(pair).currency.id.rawCurrencyId == initialCurrency.id.rawCurrencyId } + + diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt index 7bd7a99414..89edac21b3 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt @@ -1,6 +1,7 @@ package com.tangem.domain.swap.models import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal @@ -13,6 +14,8 @@ data class SwapTransactionListModel( val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, val toCryptoCurrency: CryptoCurrency, + val fromAccount: Account.CryptoPortfolio?, + val toAccount: Account.CryptoPortfolio?, val transactions: List, ) diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt index 5f9a18e411..7fe9292ea2 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.swap +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet @@ -20,12 +21,17 @@ interface SwapTransactionRepository { * @param userWalletId selected user wallet id * @param fromCryptoCurrency currency swap from * @param toCryptoCurrency currency swap to + * @param fromAccount account swap from + * @param toAccount account swap to * @param transaction swap transaction */ + @Suppress("LongParameterList") suspend fun storeTransaction( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transaction: SwapTransactionModel, ) diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt index 8d6869366a..be872f8701 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt @@ -3,6 +3,7 @@ package com.tangem.domain.swap.usecase import arrow.core.Either import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.ExpressProviderType.Companion.shouldStoreSwapTransaction +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.SwapErrorResolver @@ -21,6 +22,8 @@ class SwapTransactionSentUseCase( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrencyStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, swapDataTransactionModel: SwapDataTransactionModel, provider: ExpressProvider, txHash: String, @@ -32,6 +35,8 @@ class SwapTransactionSentUseCase( userWalletId = userWallet.walletId, fromCryptoCurrency = fromCryptoCurrencyStatus.currency, toCryptoCurrency = toCryptoCurrencyStatus.currency, + fromAccount = fromAccount, + toAccount = toAccount, transaction = SwapTransactionModel( txId = swapDataTransactionModel.txId, provider = provider, diff --git a/domain/tokens/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/error/TokenListSortingError.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListSortingError.kt index 847432134b..eb03e4aa5e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListSortingError.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/error/TokenListSortingError.kt @@ -8,5 +8,7 @@ sealed class TokenListSortingError { object UnableToSortTokenList : TokenListSortingError() + data class UnableToSortAccounts(val accountIds: List) : TokenListSortingError() + data class DataError(val cause: Throwable) : TokenListSortingError() } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index 826fb686d5..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/visa/detekt-baseline-debug.xml b/domain/visa/detekt-baseline-debug.xml new file mode 100644 index 0000000000..6f7affc0e3 --- /dev/null +++ b/domain/visa/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + SuspendFunSwallowedCancellation:SetVisaPinCodeUseCase.kt$SetVisaPinCodeUseCase$runCatching + + 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/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..40a1132513 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayInitialCredentials.kt @@ -0,0 +1,3 @@ +package com.tangem.domain.visa.model + +data class TangemPayInitialCredentials(val customerWalletAddress: String, val authTokens: VisaAuthTokens) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt index 6793255df3..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..69ec635f4a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -1,11 +1,12 @@ package com.tangem.domain.pay.datasource import arrow.core.Either +import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.VisaAuthTokens interface TangemPayAuthDataSource { - suspend fun generateNewAuthTokens(address: String, cardId: String): Either + suspend fun produceInitialCredentials(cardId: String): Either suspend fun refreshAuthTokens(refreshToken: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 9937d2e1ff..455e70bd12 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -15,8 +15,14 @@ data class CustomerInfo( data class ProductInstance( val id: String, - val status: String, - ) + val cardId: String, + val status: Status, + ) { + enum class Status { + ACTIVE, + INACTIVE, + } + } data class CardInfo( val lastFourDigits: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt new file mode 100644 index 0000000000..7631289549 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.pay.model + +enum class SetPinResult { + SUCCESS, PIN_TOO_WEAK, DECRYPTION_ERROR, UNKNOWN_ERROR +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt 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/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index 5c93a97e19..88d98ab106 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -9,6 +9,10 @@ interface OnboardingRepository { suspend fun validateDeeplink(link: String): Either + suspend fun isTangemPayInitialDataProduced(): Boolean + + suspend fun produceInitialData() + suspend fun getCustomerInfo(): Either suspend fun createOrder(): Either diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt new file mode 100644 index 0000000000..6ce352de48 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.pay.model.SetPinResult +import com.tangem.domain.pay.model.TangemPayCardBalance +import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import kotlinx.coroutines.flow.Flow + +interface TangemPayCardDetailsRepository { + + suspend fun getCardBalance(): Either + + suspend fun revealCardDetails(): Either + + suspend fun setPin(pin: String): Either + + suspend fun isAddToWalletDone(): Either + + suspend fun setAddToWalletAsDone(): Either + + suspend fun freezeCard(cardId: String): Either + suspend fun unfreezeCard(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..bddeb01eaf --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ProduceTangemPayInitialDataUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.domain.pay.repository.OnboardingRepository + +class ProduceTangemPayInitialDataUseCase( + private val repository: OnboardingRepository, +) { + + suspend operator fun invoke(): Either { + return catch { + val isDataProduced = repository.isTangemPayInitialDataProduced() + if (isDataProduced) { + return@catch Unit + } else { + repository.produceInitialData() + } + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/GetTangemPayCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/GetTangemPayCurrencyStatusUseCase.kt new file mode 100644 index 0000000000..a6f4b0baf1 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/GetTangemPayCurrencyStatusUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.tangempay + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import java.math.BigDecimal + +interface GetTangemPayCurrencyStatusUseCase { + + suspend operator fun invoke( + currency: CryptoCurrency, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + depositAddress: String, + ): CryptoCurrencyStatus? +} \ No newline at end of file diff --git a/domain/wallet-connect/detekt-baseline-debug.xml b/domain/wallet-connect/detekt-baseline-debug.xml new file mode 100644 index 0000000000..9f061ccbe0 --- /dev/null +++ b/domain/wallet-connect/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + UseEmptyCounterpart:WcAnalyticEvents.kt$WcAnalyticEvents$mapOf() + + diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt index 7931afed92..49ae45531a 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt @@ -1,12 +1,14 @@ package com.tangem.domain.walletconnect.model import com.domain.blockaid.models.dapp.CheckDAppResult +import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.Network import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession import com.tangem.domain.models.wallet.UserWallet data class WcSession( val wallet: UserWallet, + val account: Account?, val networks: Set, val sdkModel: WcSdkSession, val securityStatus: CheckDAppResult, diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt index 1122347614..e55e69f940 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt @@ -1,9 +1,11 @@ package com.tangem.domain.walletconnect.model +import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet data class WcSessionApprove( val wallet: UserWallet, + val account: Account?, val network: List, ) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt index 15ccc11fc1..eaee191c92 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt @@ -2,12 +2,14 @@ package com.tangem.domain.walletconnect.model import com.domain.blockaid.models.dapp.CheckDAppResult import com.squareup.moshi.JsonClass +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId @JsonClass(generateAdapter = true) data class WcSessionDTO( val topic: String, val walletId: UserWalletId, + val accountId: AccountId? = null, val url: String?, val securityStatus: CheckDAppResult = CheckDAppResult.FAILED_TO_VERIFY, val connectingTime: Long? = null, diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt index c6d611f166..dbec25e6d2 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt @@ -1,18 +1,22 @@ package com.tangem.domain.walletconnect.model import com.domain.blockaid.models.dapp.CheckDAppResult +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.network.Network -import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData data class WcSessionProposal( val dAppMetaData: WcAppMetaData, val proposalNetwork: Map, + val proposalAccountNetwork: Map?, val securityStatus: CheckDAppResult, ) { data class ProposalNetwork( val wallet: UserWallet, + val account: Account?, val missingRequired: Set, val required: Set, val available: Set, diff --git a/domain/wallets/build.gradle.kts b/domain/wallets/build.gradle.kts index 9091554999..8f794ae784 100644 --- a/domain/wallets/build.gradle.kts +++ b/domain/wallets/build.gradle.kts @@ -30,6 +30,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/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..a10a8c5492 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -1,9 +1,9 @@ package com.tangem.domain.wallets.repository import arrow.core.Either -import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.models.UserWalletRemoteInfo import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import kotlinx.coroutines.flow.Flow @@ -45,6 +45,8 @@ interface WalletsRepository { suspend fun markWallet2WasCreated(userWalletId: UserWalletId) + suspend fun createWallet(userWalletId: UserWalletId) + fun nftEnabledStatus(userWalletId: UserWalletId): Flow fun nftEnabledStatuses(): Flow> diff --git a/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/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/detekt-baseline-debug.xml b/domain/yield-supply/detekt-baseline-debug.xml new file mode 100644 index 0000000000..7bb0bea0e1 --- /dev/null +++ b/domain/yield-supply/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + NestedScopeFunctions:YieldSupplyGetRewardsBalanceUseCase.kt$YieldSupplyGetRewardsBalanceUseCase$let { rate -> amt.multiply(rate) } + UnnecessaryNotNullCheck:YieldSupplyGetProtocolBalanceUseCase.kt$YieldSupplyGetProtocolBalanceUseCase$requireNotNull(cryptoCurrency as CryptoCurrency.Token) + + 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/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..e9302796ad 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 @@ -80,7 +80,7 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi color = TangemTheme.colors.text.tertiary, ) } - if (state.buttonState.showProgress) { + if (state.buttonState.shouldShowProgress) { focusManager.clearFocus() keyboardController?.hide() } @@ -89,7 +89,7 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi .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, ) @@ -129,15 +129,15 @@ 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) @@ -321,7 +321,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,55 @@ internal class AccountDetailsModel @Inject constructor( } } - private fun getInitialState(): AccountDetailsUM { - val account = params.account + private fun failedArchiveDialog(error: ArchiveCryptoPortfolioUseCase.Error) { + // todo account referral case + val titleRes = when (error) { + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated, + is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed, + -> R.string.common_something_went_wrong + } + val messageRes = when (error) { + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated, + is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed, + -> R.string.account_could_not_archive + } + + val dialogMessage = DialogMessage( + title = resourceReference(titleRes), + message = resourceReference(messageRes), + ) + messageSender.send(dialogMessage) + } + + private fun buildUI(account: Account): AccountDetailsUM { val archiveMode = when (account) { is Account.CryptoPortfolio -> when (account.isMainAccount) { - true -> AccountDetailsUM.ArchiveMode.None - false -> AccountDetailsUM.ArchiveMode.Available( + true -> ArchiveMode.None + false -> ArchiveMode.Available( onArchiveAccountClick = ::onArchiveAccountClick, + isLoading = false, ) } } - val isMultiCurrency = getUserWalletUseCase(params.account.accountId.userWalletId) - .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..1d0afaa6d2 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -91,21 +92,33 @@ private fun ArchiveAccountRow(state: AccountDetailsUM.ArchiveMode.Available) { .fillMaxWidth() .clip(RoundedCornerShape(TangemTheme.dimens.radius12)) .background(TangemTheme.colors.background.primary) - .clickable(onClick = state.onArchiveAccountClick) + .clickable(enabled = !state.isLoading, onClick = state.onArchiveAccountClick) .padding(all = TangemTheme.dimens.spacing12), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - Icon( - tint = TangemTheme.colors.icon.warning, - imageVector = ImageVector.vectorResource(id = R.drawable.ic_archive_24), - contentDescription = null, - ) - Text( - text = stringResourceSafe(R.string.account_details_archive), - color = TangemTheme.colors.text.warning, - style = TangemTheme.typography.subtitle1, - ) + if (state.isLoading) { + CircularProgressIndicator( + color = TangemTheme.colors.text.disabled, + modifier = Modifier.size(TangemTheme.dimens.size24), + ) + Text( + text = stringResourceSafe(R.string.account_details_archive), + color = TangemTheme.colors.text.disabled, + style = TangemTheme.typography.subtitle1, + ) + } else { + Icon( + tint = TangemTheme.colors.icon.warning, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_archive_24), + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.account_details_archive), + color = TangemTheme.colors.text.warning, + style = TangemTheme.typography.subtitle1, + ) + } } } @@ -174,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/detekt-baseline-debug.xml b/features/create-wallet-selection/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..5cabd0af5c --- /dev/null +++ b/features/create-wallet-selection/impl/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + BooleanPropertyNaming:CreateWalletSelectionUM.kt$CreateWalletSelectionUM$val showAlreadyHaveWallet: Boolean = false + MultilineLambdaItParameter:CreateWalletSelectionContent.kt${ Feature( feature = it, modifier = Modifier .padding(top = 12.dp), ) } + + diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 20b64f77ca..9a81297bdd 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -94,7 +94,7 @@ internal class CreateWalletSelectionModel @Inject constructor( } private fun onHardwareWalletClick() { - // TODO [REDACTED_TASK_KEY] + router.push(AppRoute.CreateHardwareWallet) } private fun onBuyClick() { diff --git a/features/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/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..511bcb27d8 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 @@ -8,7 +8,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 +23,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 +37,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 +45,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 +93,20 @@ 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") + } + } + } else { + router.push(AppRoute.WalletSettings(userWalletId)) + } + } } \ No newline at end of file diff --git a/features/disclaimer/impl/detekt-baseline-debug.xml b/features/disclaimer/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..d104e3978e --- /dev/null +++ b/features/disclaimer/impl/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + NullableToStringCall:DisclaimerWebViewClient.kt$DisclaimerWebViewClient$$url + + 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/detekt-baseline-debug.xml b/features/hot-wallet/api/detekt-baseline-debug.xml new file mode 100644 index 0000000000..a4cc7258d2 --- /dev/null +++ b/features/hot-wallet/api/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + BooleanPropertyNaming:CreateWalletBackupComponent.kt$CreateWalletBackupComponent.Params$val setAccessCode: Boolean + + 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..c8de6fdca5 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,8 @@ interface CreateWalletBackupComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, + val isUpgradeFlow: Boolean, + val setAccessCode: Boolean, ) interface Factory : ComponentFactory diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ForgetWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ForgetWalletComponent.kt new file mode 100644 index 0000000000..a363de47e0 --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ForgetWalletComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface ForgetWalletComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpdateAccessCodeComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpdateAccessCodeComponent.kt index b2416c8d0a..3ca8f98314 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,8 @@ 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, + ) 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..75e6e8dacb --- /dev/null +++ b/features/hot-wallet/impl/detekt-baseline-debug.xml @@ -0,0 +1,67 @@ + + + + + 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:CreateMobileWalletModel.kt$CreateMobileWalletModel${ Timber.e(it) uiState.update { it.copy(createButtonLoading = false) } } + 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:CreateMobileWalletModel.kt$CreateMobileWalletModel${ it.copy(createButtonLoading = false) } + 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:AccessCodeModel.kt$AccessCodeModel$runCatching + SuspendFunSwallowedCancellation:AddExistingWalletImportModel.kt$AddExistingWalletImportModel$runCatching + SuspendFunSwallowedCancellation:CreateMobileWalletModel.kt$CreateMobileWalletModel$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..96fc1b4b46 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 @@ -27,6 +27,7 @@ import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.UnlockHotWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -36,6 +37,9 @@ import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.resume +private const val SUCCESS_DISPLAY_DURATION_MS = 200L +private const val ERROR_DISPLAY_DURATION_MS = 500L + @Suppress("LongParameterList") @Stable @ModelScoped @@ -59,49 +63,93 @@ 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) { + showSuccessAndProceed(params.accessCodeToConfirm) + } else { + showErrorAndReset() + } + } + } else { + onNewCodeSet() + } + } } - private fun onButtonClick() { - if (params.accessCodeToConfirm == null) { - params.callbacks.onNewAccessCodeInput(params.userWalletId, uiState.value.accessCode) - } else { - setCode(params.userWalletId, params.accessCodeToConfirm) + private fun onNewCodeSet() { + val accessCode = uiState.value.accessCode + uiState.update { currentState -> + currentState.copy( + accessCode = "", + ) + } + params.callbacks.onNewAccessCodeInput(params.userWalletId, accessCode) + } + + private suspend fun showSuccessAndProceed(accessCodeToConfirm: String) { + uiState.update { currentState -> + currentState.copy( + accessCodeColor = PinTextColor.Success, + ) + } + delay(timeMillis = SUCCESS_DISPLAY_DURATION_MS) + setCode(params.userWalletId, accessCodeToConfirm) + } + + 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) - } - runCatching { val userWallet = getUserWalletUseCase(userWalletId) .getOrElse { error("User wallet with id $userWalletId not found") } @@ -150,10 +198,6 @@ internal class AccessCodeModel @Inject constructor( params.callbacks.onAccessCodeUpdated(params.userWalletId) }.onFailure { Timber.e(it) - - uiState.update { - it.copy(buttonInProgress = false) - } } } } 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..99114079d4 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.hotwallet.addexistingwallet.entry import com.arkivanov.decompose.router.stack.* import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -13,6 +14,7 @@ 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.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent @@ -32,6 +34,8 @@ internal class AddExistingWalletModel @Inject constructor( private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val trackingContextProxy: TrackingContextProxy, + private val setAccessCodeSkippedUseCase: SetAccessCodeSkippedUseCase, ) : Model() { val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback() @@ -45,6 +49,15 @@ internal class AddExistingWalletModel @Inject constructor( val startRoute = AddExistingWalletRoute.Import val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + init { + trackingContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + trackingContextProxy.removeContext() + } + fun onChildBack() { when (currentRoute.value) { is AddExistingWalletRoute.Import -> router.pop() @@ -72,6 +85,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 +101,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, ), @@ -109,6 +135,8 @@ internal class AddExistingWalletModel @Inject constructor( override fun onContinueClick(userWalletId: UserWalletId) { stackNavigation.replaceAll(AddExistingWalletRoute.SetAccessCode(userWalletId)) } + + override fun onUpgradeClick(userWalletId: UserWalletId) = Unit } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { @@ -137,7 +165,7 @@ internal class AddExistingWalletModel @Inject constructor( inner class MobileWalletSetupFinishedComponentModelCallbacks : 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..78f81490ce 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt @@ -34,6 +34,7 @@ internal class AddExistingWalletChildFactory @Inject constructor( params = ManualBackupCompletedComponent.Params( userWalletId = route.userWalletId, callbacks = model.manualBackupCompletedComponentModelCallbacks, + isImportFlow = true, ), ) is AddExistingWalletRoute.SetAccessCode -> accessCodeComponentFactory.create( diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt new file mode 100644 index 0000000000..992eefd69f --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt @@ -0,0 +1,183 @@ +package com.tangem.features.hotwallet.createhardwarewallet + +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic.SignedIn +import com.tangem.core.analytics.models.Basic.SignedIn.SignInType +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.analytics.ParamCardCurrencyConverter +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.SaveWalletError +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val HIDE_PROGRESS_DELAY = 400L + +@Suppress("LongParameterList") +@ModelScoped +internal class CreateHardwareWalletModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val uiMessageSender: UiMessageSender, + private val scanCardProcessor: ScanCardProcessor, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + private val saveWalletUseCase: SaveWalletUseCase, + private val userWalletsListRepository: UserWalletsListRepository, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow( + CreateHardwareWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onScanDeviceClick = ::onScanDeviceClick, + ), + ) + + override fun onDestroy() { + super.onDestroy() + } + + private fun onBuyTangemWalletClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + private fun onScanDeviceClick() { + scanCard() + } + + private fun scanCard() { + modelScope.launch { + setLoading(true) + + val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() + cardSdkConfigRepository.setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = shouldSaveAccessCodes, + ) + + val analyticsSource = AnalyticsParam.ScreensSources.Intro + + scanCardProcessor.scan( + analyticsSource = analyticsSource, + onProgressStateChange = { showProgress -> + if (!showProgress) { + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + } else { + setLoading(true) + } + }, + onFailure = { error -> + handleScanError(error) + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + }, + onSuccess = { scanResponse -> + proceedWithScanResponse(scanResponse) + }, + ) + } + } + + private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) { + val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build() + + if (userWallet == null) { + Timber.e("User wallet not created") + setLoading(false) + return + } + + saveWalletUseCase(userWallet = userWallet).fold( + ifLeft = { + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + when (it) { + is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") + is SaveWalletError.WalletAlreadySaved -> { + userWalletsListRepository.unlock( + userWalletId = userWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + ).onRight { + router.replaceAll(AppRoute.Wallet) + } + } + } + }, + ifRight = { + setLoading(false) + sendSignedInCardAnalyticsEvent(scanResponse = scanResponse, isImported = userWallet.isImported) + router.replaceAll(AppRoute.Wallet) + }, + ) + } + + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) { + val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) + if (currency != null) { + analyticsEventHandler.send( + SignedIn( + currency = currency, + batch = scanResponse.card.batchId, + signInType = SignInType.Card, + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), + isImported = isImported, + hasBackup = scanResponse.card.backupStatus?.isActive, + ), + ) + } + } + + private fun setLoading(isLoading: Boolean) { + uiState.update { it.copy(isScanInProgress = isLoading) } + } + + private fun handleScanError(error: TangemError) { + when (error) { + is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable() + is TangemSdkError -> Timber.e(error, "Scan error occurred") + else -> Timber.e(error, "Error happened") + } + } + + private fun handleNfcFeatureUnavailable() { + uiMessageSender.send( + message = DialogMessage( + message = resourceReference(R.string.nfc_error_unavailable), + title = resourceReference(id = R.string.common_error), + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/DefaultCreateHardwareWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/DefaultCreateHardwareWalletComponent.kt new file mode 100644 index 0000000000..0f83065531 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/DefaultCreateHardwareWalletComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.hotwallet.createhardwarewallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.hotwallet.CreateHardwareWalletComponent +import com.tangem.features.hotwallet.createhardwarewallet.ui.CreateHardwareWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultCreateHardwareWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: Unit, +) : CreateHardwareWalletComponent, AppComponentContext by context { + + private val model: CreateHardwareWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + CreateHardwareWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : CreateHardwareWalletComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultCreateHardwareWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/di/CreateHardwareWalletModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/di/CreateHardwareWalletModule.kt new file mode 100644 index 0000000000..6440dc7bb6 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/di/CreateHardwareWalletModule.kt @@ -0,0 +1,27 @@ +package com.tangem.features.hotwallet.createhardwarewallet.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.CreateHardwareWalletComponent +import com.tangem.features.hotwallet.createhardwarewallet.CreateHardwareWalletModel +import com.tangem.features.hotwallet.createhardwarewallet.DefaultCreateHardwareWalletComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(SingletonComponent::class) +internal interface CreateHardwareWalletModule { + + @Binds + fun bindCreateHardwareWalletComponentFactory( + impl: DefaultCreateHardwareWalletComponent.Factory, + ): CreateHardwareWalletComponent.Factory + + @Binds + @IntoMap + @ClassKey(CreateHardwareWalletModel::class) + fun bindCreateHardwareWalletModel(model: CreateHardwareWalletModel): Model +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/entity/CreateHardwareWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/entity/CreateHardwareWalletUM.kt new file mode 100644 index 0000000000..814ead9ec4 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/entity/CreateHardwareWalletUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.hotwallet.createhardwarewallet.entity + +internal data class CreateHardwareWalletUM( + val onBackClick: () -> Unit, + val onBuyTangemWalletClick: () -> Unit, + val onScanDeviceClick: () -> Unit, + val isScanInProgress: Boolean = false, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/ui/CreateHardwareWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/ui/CreateHardwareWalletContent.kt new file mode 100644 index 0000000000..8f2ce41cfd --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/ui/CreateHardwareWalletContent.kt @@ -0,0 +1,130 @@ +package com.tangem.features.hotwallet.createhardwarewallet.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.feature.FeatureBlock +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CreateHardwareWalletContent(state: CreateHardwareWalletUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier + .statusBarsPadding(), + startButton = TopAppBarButtonUM.Back(state.onBackClick), + title = TextReference.EMPTY, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + ) { + Icon( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.ic_tangem_64), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 20.dp, + end = 16.dp, + ), + text = stringResourceSafe(R.string.wallet_create_common_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 32.dp), + title = stringResourceSafe(R.string.hw_upgrade_key_migration_title), + description = stringResourceSafe(R.string.hw_upgrade_key_migration_description), + iconRes = R.drawable.ic_mobile_security_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_funds_access_title), + description = stringResourceSafe(R.string.hw_upgrade_funds_access_description), + iconRes = R.drawable.ic_knight_shield_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_general_security_title), + description = stringResourceSafe(R.string.hw_upgrade_general_security_description), + iconRes = R.drawable.ic_protect_24, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.details_buy_wallet), + onClick = state.onBuyTangemWalletClick, + ) + PrimaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.home_button_scan), + onClick = state.onScanDeviceClick, + iconResId = R.drawable.ic_tangem_24, + showProgress = state.isScanInProgress, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewCreateHardwareWalletContent() { + TangemThemePreview { + CreateHardwareWalletContent( + state = CreateHardwareWalletUM( + onBackClick = {}, + onBuyTangemWalletClick = {}, + onScanDeviceClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt index 5f8dae3197..800b694885 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.hotwallet.createmobilewallet import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.navigation.Router @@ -25,6 +26,7 @@ internal class CreateMobileWalletModel @Inject constructor( private val saveUserWalletUseCase: SaveWalletUseCase, private val router: Router, private val tangemHotSdk: TangemHotSdk, + private val trackingContextProxy: TrackingContextProxy, ) : Model() { internal val uiState: StateFlow @@ -37,6 +39,15 @@ internal class CreateMobileWalletModel @Inject constructor( ), ) + init { + trackingContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + trackingContextProxy.removeContext() + } + private fun onImportClick() { router.push(AppRoute.AddExistingWallet) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt index a53d0f587d..1cdcdcf836 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt @@ -15,6 +15,7 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.feature.FeatureBlock import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -103,39 +104,6 @@ internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Mo } } -@Composable -private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { - Row( - modifier = modifier, - ) { - Icon( - modifier = Modifier - .padding(horizontal = 12.dp), - painter = painterResource(iconRes), - contentDescription = null, - tint = TangemTheme.colors.icon.primary1, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) { - Text( - text = title, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier - .padding(top = 4.dp), - text = description, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } -} - @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt index 9b0a2821e7..6d7bc20a66 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt @@ -3,10 +3,13 @@ package com.tangem.features.hotwallet.createwalletbackup import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.push +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.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.features.hotwallet.CreateWalletBackupComponent import com.tangem.features.hotwallet.createwalletbackup.routing.CreateWalletBackupRoute @@ -23,6 +26,7 @@ internal class CreateWalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, + private val trackingContextProxy: TrackingContextProxy, ) : Model() { val params = paramsContainer.require() @@ -36,6 +40,15 @@ internal class CreateWalletBackupModel @Inject constructor( val startRoute = CreateWalletBackupRoute.RecoveryPhraseStart val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + init { + trackingContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + trackingContextProxy.removeContext() + } + fun onBack() { when (currentRoute.value) { is CreateWalletBackupRoute.RecoveryPhraseStart -> router.pop() @@ -54,11 +67,19 @@ internal class CreateWalletBackupModel @Inject constructor( } fun onManualBackupChecked() { - stackNavigation.push(CreateWalletBackupRoute.BackupCompleted) + stackNavigation.push(CreateWalletBackupRoute.BackupCompleted(isUpgradeFlow = params.isUpgradeFlow)) } fun onManualBackupCompleted() { - router.pop() + if (params.setAccessCode) { + router.replaceCurrent( + route = AppRoute.UpdateAccessCode( + userWalletId = params.userWalletId, + ), + ) + } else { + router.pop() + } } inner class ManualBackupStartModelCallbacks : ManualBackupStartComponent.ModelCallbacks { @@ -83,5 +104,14 @@ internal class CreateWalletBackupModel @Inject constructor( override fun onContinueClick(userWalletId: UserWalletId) { onManualBackupCompleted() } + + override fun onUpgradeClick(userWalletId: UserWalletId) { + router.popTo() + router.push( + AppRoute.UpgradeWallet( + userWalletId = userWalletId, + ), + ) + } } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt index 44d740af6c..4bbcc396c6 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt @@ -16,31 +16,32 @@ internal class CreateWalletBackupChildFactory @Inject constructor() { childContext: AppComponentContext, model: CreateWalletBackupModel, ): ComposableContentComponent = when (route) { - CreateWalletBackupRoute.RecoveryPhraseStart -> ManualBackupStartComponent( + is CreateWalletBackupRoute.RecoveryPhraseStart -> ManualBackupStartComponent( context = childContext, params = ManualBackupStartComponent.Params( callbacks = model.manualBackupStartModelCallbacks, ), ) - CreateWalletBackupRoute.RecoveryPhrase -> ManualBackupPhraseComponent( + is CreateWalletBackupRoute.RecoveryPhrase -> ManualBackupPhraseComponent( context = childContext, params = ManualBackupPhraseComponent.Params( userWalletId = model.params.userWalletId, callbacks = model.manualBackupPhraseModelCallbacks, ), ) - CreateWalletBackupRoute.ConfirmBackup -> ManualBackupCheckComponent( + is CreateWalletBackupRoute.ConfirmBackup -> ManualBackupCheckComponent( context = childContext, params = ManualBackupCheckComponent.Params( userWalletId = model.params.userWalletId, callbacks = model.manualBackupCheckModelCallbacks, ), ) - CreateWalletBackupRoute.BackupCompleted -> ManualBackupCompletedComponent( + is CreateWalletBackupRoute.BackupCompleted -> ManualBackupCompletedComponent( context = childContext, params = ManualBackupCompletedComponent.Params( userWalletId = model.params.userWalletId, callbacks = model.manualBackupCompletedModelCallbacks, + isUpgradeFlow = route.isUpgradeFlow, ), ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt index f063a1f796..f210f5b8e7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt @@ -15,5 +15,7 @@ internal sealed interface CreateWalletBackupRoute { data object ConfirmBackup : CreateWalletBackupRoute @Serializable - data object BackupCompleted : CreateWalletBackupRoute + data class BackupCompleted( + val isUpgradeFlow: Boolean, + ) : CreateWalletBackupRoute } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/DefaultForgetWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/DefaultForgetWalletComponent.kt new file mode 100644 index 0000000000..919cfbddf7 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/DefaultForgetWalletComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.hotwallet.forgetwallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.hotwallet.ForgetWalletComponent +import com.tangem.features.hotwallet.forgetwallet.ui.ForgetWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultForgetWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: ForgetWalletComponent.Params, +) : ForgetWalletComponent, AppComponentContext by context { + + private val model: ForgetWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + ForgetWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : ForgetWalletComponent.Factory { + override fun create( + context: AppComponentContext, + params: ForgetWalletComponent.Params, + ): DefaultForgetWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt new file mode 100644 index 0000000000..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..566fb7cbbd 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,13 @@ 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, ) } \ 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..626e492bfa 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,20 @@ internal class ManualBackupCompletedModel @Inject constructor( internal val uiState: StateFlow field = MutableStateFlow( ManualBackupCompletedUM( - onContinueClick = { params.callbacks.onContinueClick(params.userWalletId) }, + onContinueClick = { + if (params.isUpgradeFlow) { + params.callbacks.onUpgradeClick(params.userWalletId) + } else { + params.callbacks.onContinueClick(params.userWalletId) + } + }, + 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..8ba7b22a4e 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,10 @@ 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 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..4809adea2e 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, @@ -65,8 +67,6 @@ internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifi PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_continue), - showProgress = false, - enabled = true, onClick = state.onContinueClick, ) } @@ -75,11 +75,30 @@ 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), + 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), + 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..82c10d1309 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,7 @@ 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.features.hotwallet.setupfinished.MobileWalletSetupFinishedComponent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject @@ -27,11 +28,13 @@ internal class UpdateAccessCodeModel @Inject constructor( val stackNavigation = StackNavigation() val startRoute: UpdateAccessCodeRoute = UpdateAccessCodeRoute.SetAccessCode(params.userWalletId) val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + val mobileWalletSetupFinishedComponentModelCallbacks = MobileWalletSetupFinishedComponentModelCallbacks() fun onChildBack() { when (currentRoute.value) { is UpdateAccessCodeRoute.SetAccessCode -> router.pop() is UpdateAccessCodeRoute.ConfirmAccessCode -> stackNavigation.pop() + is UpdateAccessCodeRoute.SetupFinished -> Unit } } @@ -40,6 +43,12 @@ internal class UpdateAccessCodeModel @Inject constructor( } 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..c030460551 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,6 +3,7 @@ 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.decompose.di.ModelScoped @@ -16,20 +17,25 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.toWrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.card.BackupValidator import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.features.hotwallet.UpgradeWalletComponent import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM +import com.tangem.features.onboarding.v2.util.ResetCardsComponent import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.extensions.localizedDescriptionRes import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -50,17 +56,21 @@ internal class UpgradeWalletModel @Inject constructor( private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase, private val tangemSdkManager: TangemSdkManager, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, ) : Model() { private val params = paramsContainer.require() - private val _uiState = MutableStateFlow( - UpgradeWalletUM( - onBackClick = { router.pop() }, - onBuyTangemWalletClick = ::onBuyTangemWalletClick, - onScanDeviceClick = ::onScanDeviceClick, - ), - ) - internal val uiState: StateFlow = _uiState + val resetCardsComponentCallbacks = ResetCardsModelCallbacks() + val startResetCardsFlow = MutableSharedFlow() + + internal val uiState: StateFlow + field = MutableStateFlow( + UpgradeWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onContinueClick = ::onContinueClick, + ), + ) override fun onDestroy() { clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) @@ -73,7 +83,7 @@ internal class UpgradeWalletModel @Inject constructor( } } - private fun onScanDeviceClick() { + private fun onContinueClick() { scanCard() } @@ -89,6 +99,22 @@ internal class UpgradeWalletModel @Inject constructor( tangemSdkManager .scanProduct() .doOnSuccess { + // Check if user attempted to upgrade before but something went wrong and a full reset is required + val userWallet = coldUserWalletBuilderFactory.create(it).build() + 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) @@ -96,11 +122,14 @@ internal class UpgradeWalletModel @Inject constructor( .doOnFailure { showCardVerificationFailedDialog(it) } + .doOnResult { + setLoading(false) + } } } private fun setLoading(isLoading: Boolean) { - _uiState.update { it.copy(isLoading = isLoading) } + uiState.update { it.copy(isLoading = isLoading) } } private fun showCardVerificationFailedDialog(error: TangemError) { @@ -143,4 +172,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..ae8297cc1b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -16,6 +17,7 @@ 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.features.hotwallet.manualbackup.check.ManualBackupCheckComponent import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class WalletActivationModel @Inject constructor( paramsContainer: ParamsContainer, @@ -39,6 +42,8 @@ internal class WalletActivationModel @Inject constructor( private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val trackingContextProxy: TrackingContextProxy, + private val setAccessCodeSkippedUseCase: SetAccessCodeSkippedUseCase, ) : Model() { val params = paramsContainer.require() @@ -52,17 +57,33 @@ 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) + init { + trackingContextProxy.addHotWalletContext() + } + + 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 +106,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 +118,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, ), @@ -136,6 +164,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 +193,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..a5986e0a60 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -1,16 +1,18 @@ package com.tangem.features.hotwallet.walletbackup.model +import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.R import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.common.routing.AppRoute import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase import com.tangem.features.hotwallet.WalletBackupComponent import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus @@ -21,12 +23,15 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class WalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, private val getUserWalletUseCase: GetUserWalletUseCase, private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, override val dispatchers: CoroutineDispatcherProvider, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, private val router: Router, ) : Model() { @@ -36,6 +41,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 +54,7 @@ internal class WalletBackupModel @Inject constructor( style = LabelStyle.REGULAR, ), googleDriveStatus = BackupStatus.ComingSoon, + onBuyClick = ::onBuyClick, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, onHardwareWalletClick = ::onHardwareWalletClick, @@ -95,6 +105,12 @@ internal class WalletBackupModel @Inject constructor( backedUp = userWallet.backedUp, ) + private fun onBuyClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + private fun onRecoveryPhraseClick() { if (uiState.value.backedUp) { getUserWalletUseCase.invoke(params.userWalletId) @@ -113,7 +129,11 @@ internal class WalletBackupModel @Inject constructor( }, ) } else { - router.push(AppRoute.CreateWalletBackup(params.userWalletId)) + router.push( + AppRoute.CreateWalletBackup( + userWalletId = params.userWalletId, + ), + ) } } @@ -132,6 +152,6 @@ internal class WalletBackupModel @Inject constructor( } private fun onHardwareWalletClick() { - router.push(AppRoute.UpgradeWallet(params.userWalletId)) + router.push(AppRoute.WalletHardwareBackup(params.userWalletId)) } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index aa68dc3e7d..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..5b85a497b5 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt @@ -0,0 +1,154 @@ +package com.tangem.features.hotwallet.wallethardwarebackup.model + +import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.icon +import com.tangem.core.ui.components.bottomsheets.message.infoBlock +import com.tangem.core.ui.components.bottomsheets.message.onClick +import com.tangem.core.ui.components.bottomsheets.message.primaryButton +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.bottomSheetMessage +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.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, +) : Model() { + + private val params = paramsContainer.require() + + private val makeBackupAtFirstAlertBS + get() = 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, + ), + ) + 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 { + showPurchaseBlockWithDelay() + } + + private fun showPurchaseBlockWithDelay() { + modelScope.launch { + delay(SHOW_PURCHASE_BLOCK_DELAY) + uiState.update { it.copy(showPurchaseBlock = true) } + } + } + + private fun onCreateNewWalletClick() { + router.push(AppRoute.CreateHardwareWallet) + } + + private fun onUpgradeCurrentWalletClick() { + val userWallet = getUserWalletUseCase.invoke(params.userWalletId) + .getOrElse { error("Cannot find user wallet with id: ${params.userWalletId.stringValue}") } + if (userWallet is UserWallet.Hot) { + if (!userWallet.backedUp) { + messageSender.send(makeBackupAtFirstAlertBS) + } else { + 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/impl/detekt-baseline-debug.xml b/features/kyc/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..65df7413d9 --- /dev/null +++ b/features/kyc/impl/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + UnnecessaryLet:DefaultKycComponent.kt$DefaultKycComponent$let { launchSdk(startInfo) } + + diff --git a/features/manage-tokens/api/detekt-baseline-debug.xml b/features/manage-tokens/api/detekt-baseline-debug.xml new file mode 100644 index 0000000000..a71e6537bf --- /dev/null +++ b/features/manage-tokens/api/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + PropertyUsedBeforeDeclaration:ManageTokensSource.kt$AddCustomTokenMode$userWalletId + + 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/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..548977f967 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -0,0 +1,358 @@ +package com.tangem.features.markets.portfolio.add.impl.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popToFirst +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.add.api.* +import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent +import com.tangem.features.markets.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val TOKEN_ACTIONS_DELAY = 500L + +@ModelScoped +@Suppress("LongParameterList") +internal class AddToPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val callbackDelegate: AddToPortfolioCallbackDelegate, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val messageSender: UiMessageSender, + val portfolioSelectorController: PortfolioSelectorController, +) : Model(), + ChooseNetworkComponent.Callbacks by callbackDelegate, + TokenActionsComponent.Callbacks by callbackDelegate, + AddTokenComponent.Callbacks by callbackDelegate { + + private val params = paramsContainer.require() + val navigation = StackNavigation() + var currentStack = listOf(AddToPortfolioRoutes.Empty) + + /* Flows that hold state and provide it to child models */ + val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() + val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() + val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() + + private val addToPortfolioManager = params.addToPortfolioManager + val portfolioFetcher = addToPortfolioManager.portfolioFetcher + val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = addToPortfolioManager.token, + source = addToPortfolioManager.analyticsParams?.source, + ) + + val featureData: Flow = combineFeatureData() + + init { + navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } + startAddToPortfolioFlow() + } + + private fun replayMutableSharedFlow() = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Suppress("LongMethod") + private fun startAddToPortfolioFlow() { + channelFlow { + fun finishFlow() { + params.callback.onDismiss() + channel.close() + } + val featureDataFlow: StateFlow = featureData + .filterIsInstance() + .map { it.availableToAddData } + .distinctUntilChanged() + .stateIn(this) + + // use snapshot data, looks like we don’t need to remap at runtime + val data = featureDataFlow.value + + // you must control it via [AddToPortfolioManager.state] + if (!data.availableToAdd) { + finishFlow() + return@channelFlow + } + + // init data flows, emits on user/code selection, updates state holder + val firstSelectedPortfolio = setupPortfolioFlow(data) + .onEach { selectedPortfolio.emit(it) } + val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio) + .onEach { selectedNetwork.emit(it) } + + val isSinglePortfolio = data.isSinglePortfolio + if (isSinglePortfolio) { + val accountId = data.availableToAddWallets.values.first() + .availableToAddAccounts.values.first() + .account.account.accountId + // force select a portfolio, triggers [selectedPortfolio] + portfolioSelectorController.selectAccount(accountId) + } else { + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) + } + + val firstPartOfNavigation: Job = firstSelectedPortfolio + .onEach { portfolio -> + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + when { + // force select a network, triggers [selectedNetwork] + isSingleAvailableNetwork -> { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // it's important to control root screen, UI depends on it(close/arrow icon) + isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) + else -> navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + .launchIn(this) + + // main flow that combine all require data + val allRequireForAdd = combine( + flow = firstSelectedNetwork, + flow2 = firstSelectedPortfolio, + transform = { a, b -> a to b }, + ) + + // suspend until all required data is selected + allRequireForAdd.first() + // line of navigation to AddToken screen is finished; cancel the job, select a new root screen + firstPartOfNavigation.cancel() + navigation.replaceAll(AddToPortfolioRoutes.AddToken) + + var middleNavigationJob: Job? = null + // handle actions from AddToken screen + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changeNetworkNavigationFlow() + .launchIn(this) + val route = routeToNetworkSelector(selectedPortfolio.first()) + navigation.pushNew(route) + } + .launchIn(this) + // handle actions from AddToken screen + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this) + navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) + } + .launchIn(this) + + // suspend until token is added + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + middleNavigationJob?.cancel() + val selectedPortfolio = selectedPortfolio.first() + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + setupTokenActionsFlow(selectedPortfolio, addedToken) + .onEach { + tokenActionsData.emit(it) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishFlow() } + .launchIn(this) + + callbackDelegate.onLaterClick.receiveAsFlow().first() + finishFlow() + } + .catch { + Timber.e(it) + params.callback.onDismiss() + } + .launchIn(modelScope) + } + + private fun changeNetworkNavigationFlow(): Flow { + return setupNetworkFlow(selectedPortfolio) + .onEach { newNetwork -> + selectedNetwork.emit(newNetwork) + navigation.popToFirst() + } + } + + private suspend fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow { + val selectedAccount = selectedPortfolio.first().account.account.account.accountId + portfolioSelectorController.selectAccount(selectedAccount) + val changedPortfolio = setupPortfolioFlow(data) + .drop(1) + .onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) } + val changedNetwork = setupNetworkFlow(changedPortfolio) + return combine( + flow = changedPortfolio, + flow2 = changedNetwork, + transform = { newPortfolio, newNetwork -> + selectedPortfolio.tryEmit(newPortfolio) + selectedNetwork.tryEmit(newNetwork) + navigation.popToFirst() + }, + ) + } + + private fun setupTokenActionsFlow( + selectedPortfolio: SelectedPortfolio, + addedToken: CryptoCurrencyStatus, + ): Flow { + val timeFlow = channelFlow { + val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } + getCryptoCurrencyActionsUseCase( + currency = addedToken.currency, + accountId = selectedPortfolio.account.account.account.accountId, + ).onEach { state -> + val requestedQuickActions = toQuickActions(state.states) + when { + requestedQuickActions.isNotEmpty() -> { + timerJob.cancel() + send(state) + } + // wait any requestedQuickActions while timer active + timerJob.isActive -> Unit + else -> close() + } + }.collect() + } + return timeFlow.map { + PortfolioData.CryptoCurrencyData( + userWallet = selectedPortfolio.userWallet, + status = addedToken, + actions = it.states, + ) + } + } + + private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( + flow = portfolioSelectorController.isAccountMode, + flow2 = portfolioSelectorController.selectedAccount, + transform = { isAccountMode, selectedAccountId -> + selectedAccountId ?: return@combine null + val availableToAddWallets = + data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null + val availableToAddAccount = + availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null + SelectedPortfolio( + isAccountMode = isAccountMode, + userWallet = availableToAddWallets.userWallet, + account = availableToAddAccount, + availableMorePortfolio = !data.isSinglePortfolio, + ) + }, + ) + .filterNotNull() + + private fun setupNetworkFlow(selectedPortfolioFlow: Flow): Flow = combine( + flow = selectedPortfolioFlow, + flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), + transform = transform@{ selectedPortfolio, selectedNetwork -> + SelectedNetwork( + cryptoCurrency = createCryptoCurrency( + userWallet = selectedPortfolio.userWallet, + network = selectedNetwork, + account = selectedPortfolio.account, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + availableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + account: AvailableToAddAccount, + ): CryptoCurrency? { + val accountIndex = when (account.account) { + is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token, + network = network, + accountIndex = accountIndex, + ) + } + + private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector { + return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) + } + + private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> + when (state) { + is AddToPortfolioManager.State.AvailableToAdd -> + portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> + val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] + ?: return@isEnabled false + val availableAccount = + availableWallet.availableToAddAccounts[accountStatus.account.accountId] + return@isEnabled availableAccount != null + } + AddToPortfolioManager.State.Init, + AddToPortfolioManager.State.NothingToAdd, + -> Unit + } + } +} + +@ModelScoped +internal class AddToPortfolioCallbackDelegate @Inject constructor() : + ChooseNetworkComponent.Callbacks, + TokenActionsComponent.Callbacks, + AddTokenComponent.Callbacks { + + val onNetworkSelected = Channel() + val onLaterClick = Channel() + val onChangeNetworkClick = Channel() + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + + override fun onNetworkSelected(network: TokenMarketInfo.Network) { + onNetworkSelected.trySend(network) + } + + override fun onLaterClick() { + onLaterClick.trySend(Unit) + } + + override fun onChangeNetworkClick() { + onChangeNetworkClick.trySend(Unit) + } + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioRoutes.kt new file mode 100644 index 0000000000..4f46fac1d3 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioRoutes.kt @@ -0,0 +1,28 @@ +package com.tangem.features.markets.portfolio.add.impl.model + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +internal sealed interface AddToPortfolioRoutes : Route { + + @Serializable + data object Empty : AddToPortfolioRoutes + + @Serializable + data object PortfolioSelector : AddToPortfolioRoutes + + @Serializable + data class NetworkSelector( + val selectedPortfolio: SelectedPortfolio, + ) : AddToPortfolioRoutes + + @Serializable + data object AddToken : AddToPortfolioRoutes + + @Serializable + data object TokenActions : AddToPortfolioRoutes +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt index 8e227d1ec8..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/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index 3b1183b867..d5e434ad2a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -16,15 +17,14 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.markets.SaveMarketTokensUseCase import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency @@ -32,11 +32,13 @@ import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent import com.tangem.features.markets.portfolio.impl.loader.PortfolioData import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.features.wallet.utils.UserWalletImageFetcher @@ -49,6 +51,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager @Suppress("LongParameterList", "LargeClass") @Stable @@ -69,6 +72,9 @@ internal class MarketsPortfolioModel @Inject constructor( private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, private val userWalletImageFetcher: UserWalletImageFetcher, private val receiveAddressesFactory: ReceiveAddressesFactory, + private val accountsFeatureToggles: AccountsFeatureToggles, + newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory, + private val newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory, ) : Model() { val state: StateFlow get() = _state @@ -80,12 +86,40 @@ internal class MarketsPortfolioModel @Inject constructor( source = params.analyticsParams?.source, ) + val newAddToPortfolioManager: NewAddToPortfolioManager? + val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate? + /** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */ private val selectedMultiWalletIdFlow = MutableStateFlow(value = null) private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel()) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + } + + private val tokenActionsHandler = tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { updateBlock -> + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) { + updateTokensState { + it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) + } + } + }, + onHandleQuickAction = { handledAction -> + analyticsEventHandler.send( + analyticsEventBuilder.quickActionClick( + actionUM = handledAction.action, + blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name, + ), + ) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + configureReceiveAddresses(handledAction) + } + }, + ) private val currentAppCurrency = getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> @@ -132,27 +166,7 @@ internal class MarketsPortfolioModel @Inject constructor( }, ), currentState = Provider { _state.value }, - tokenActionsHandler = tokenActionsIntentsFactory.create( - currentAppCurrency = Provider { currentAppCurrency.value }, - updateTokenReceiveBSConfig = { updateBlock -> - if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) { - updateTokensState { - it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) - } - } - }, - onHandleQuickAction = { handledAction -> - analyticsEventHandler.send( - analyticsEventBuilder.quickActionClick( - actionUM = handledAction.action, - blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name, - ), - ) - if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { - configureReceiveAddresses(handledAction) - } - }, - ), + tokenActionsHandler = tokenActionsHandler, updateTokens = { updateBlock -> updateTokensState { state -> state.copy(tokens = updateBlock(state.tokens)) @@ -161,18 +175,50 @@ internal class MarketsPortfolioModel @Inject constructor( ) init { - // Subscribe on selected wallet flow to support actual selected wallet - subscribeOnSelectedMultiWalletUpdates() + if (accountsFeatureToggles.isFeatureEnabled) { + newAddToPortfolioManager = newAddToPortfolioManagerFactory + .create( + modelScope, + params.token, + params.analyticsParams, + ) + newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create( + scope = modelScope, + token = params.token, + tokenActionsHandler = tokenActionsHandler, + buttonState = newAddToPortfolioManager.state.map { + when (it) { + is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available + NewAddToPortfolioManager.State.Init -> AddButtonState.Loading + NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable + } + }, + onAddClick = { bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio) }, + ) + newMarketsPortfolioDelegate.combineData() + .onEach { _state.value = it } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } else { + newAddToPortfolioManager = null + newMarketsPortfolioDelegate = null + // Subscribe on selected wallet flow to support actual selected wallet + subscribeOnSelectedMultiWalletUpdates() - subscribeOnStateUpdates() + subscribeOnStateUpdates() + } } fun setTokenNetworks(networks: List) { addToPortfolioManager.setAvailableNetworks(networks) + newAddToPortfolioManager?.setTokenNetworks(networks) + newMarketsPortfolioDelegate?.setTokenNetworks(networks) } fun setNoNetworksAvailable() { addToPortfolioManager.setAvailableNetworks(emptyList()) + newAddToPortfolioManager?.setTokenNetworks(emptyList()) + newMarketsPortfolioDelegate?.setTokenNetworks(emptyList()) } private fun subscribeOnSelectedMultiWalletUpdates() { @@ -378,7 +424,7 @@ internal class MarketsPortfolioModel @Inject constructor( status = quickAction.cryptoCurrencyData.status, userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, ) ?: return@launch - bottomSheetNavigation.activate(tokenConfig) + bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig)) } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioRoute.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioRoute.kt new file mode 100644 index 0000000000..576d9cda78 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioRoute.kt @@ -0,0 +1,17 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.models.TokenReceiveConfig +import kotlinx.serialization.Serializable + +@Serializable +sealed interface MarketsPortfolioRoute : Route { + + @Serializable + data object AddToPortfolio : MarketsPortfolioRoute + + @Serializable + data class TokenReceive( + val config: TokenReceiveConfig, + ) : MarketsPortfolioRoute +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt new file mode 100644 index 0000000000..fd1f0fecea --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -0,0 +1,334 @@ +package com.tangem.features.markets.portfolio.impl.model + +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioHeader +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioListItem +import com.tangem.features.markets.portfolio.impl.ui.state.WalletHeader +import com.tangem.utils.extensions.isZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongParameterList") +internal class NewMarketsPortfolioDelegate @AssistedInject constructor( + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val allAccountSupplier: MultiAccountStatusListSupplier, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getUserWalletUseCase: GetUserWalletUseCase, + isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + @Assisted private val scope: CoroutineScope, + @Assisted private val token: TokenMarketParams, + @Assisted private val tokenActionsHandler: TokenActionsHandler, + @Assisted private val buttonState: Flow, + @Assisted private val onAddClick: () -> Unit, +) { + + private val currencyRawId: CryptoCurrency.RawID = token.id + private var expandedHolder: MutableStateFlow>>? = null + + private val settingsFlow: Flow = combine( + flow = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + flow3 = isAccountsModeEnabledUseCase(), + transform = ::SettingsBox, + ).shareIn( + replay = 1, + started = SharingStarted.Eagerly, + scope = scope, + ).distinctUntilChanged() + + private val availableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + fun setTokenNetworks(networks: List) { + availableNetworks.tryEmit(networks) + } + + fun combineData(): Flow { + return availableNetworks.transformLatest { availableNetworks -> + when { + availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable) + else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged()) + } + }.distinctUntilChanged() + } + + private fun onAvailableNetworksFlow(): Flow = + portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency -> + when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) { + false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged()) + true -> when (portfolioWithCurrency.hasMultiWallets) { + true -> emitAll(addFirstTokenFlow()) + false -> emit(MyPortfolioUM.UnavailableForWallet) + } + } + } + + private fun addFirstTokenFlow(): Flow = buttonState.map { + when (it) { + AddButtonState.Loading -> MyPortfolioUM.Loading + AddButtonState.Available -> MyPortfolioUM.AddFirstToken( + onAddClick = onAddClick, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + ) + AddButtonState.Unavailable -> MyPortfolioUM.Unavailable + } + } + + private fun contentFlow(portfolio: PortfoliosWithThisCurrency): Flow { + fun Portfolio.actionsFoAccountCurrencies(): List>> = + accountsWithAdded.map { account -> + fun CryptoCurrencyStatus.actionsFlow() = getCryptoCurrencyActionsUseCase( + accountId = account.accountStatus.account.accountId, + currency = this.currency, + ).map { actionsState -> actionsState.cryptoCurrencyStatus.currency to actionsState } + account.addedCurrency.map { it.actionsFlow() } + }.flatten() + + val allAddedTokenActions = + portfolio.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten() + + return combine( + flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(), + flow2 = buttonState.distinctUntilChanged(), + flow3 = getExpandedHolder(portfolio), + flow4 = settingsFlow.distinctUntilChanged(), + transform = { actions, addButtonState, expanded, settings -> + buildContentState( + portfolio = portfolio, + allActions = actions, + addButtonState = addButtonState, + expanded = expanded, + settings = settings, + ) + }, + ) + } + + private fun getExpandedHolder( + portfolio: PortfoliosWithThisCurrency, + ): StateFlow>> { + val expandedHolder = this.expandedHolder + if (expandedHolder != null) return expandedHolder + val allAddedCurrency = portfolio.flattenAddedCurrency + val shouldForceExpand = allAddedCurrency.size == 1 && + allAddedCurrency.first().value.amount?.isZero() == true + + val initValue = when { + shouldForceExpand -> { + val currency = allAddedCurrency.first() + // find userWallet than have this single added token + portfolio.portfolios + .find { it.accountsWithAdded.find { account -> account.addedCurrency.isNotEmpty() } != null } + ?.userWallet + ?.let { setOf(it.walletId to currency.currency.id) } + ?: setOf() + } + else -> setOf() + } + return MutableStateFlow(initValue) + .also { this.expandedHolder = it } + } + + private fun portfolioWithThisCurrencyFLow(): Flow = + allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows -> + combine(flows) { + PortfoliosWithThisCurrency( + currencyRawId = currencyRawId, + portfolios = it.toList(), + ) + } + }.distinctUntilChanged() + + private fun AccountStatusList.addedAccountsFlow(): Flow = + getUserWalletUseCase.invokeFlow(this.userWalletId).mapNotNull { it.getOrNull() }.map { wallet -> + Portfolio( + userWallet = wallet, + accountStatusList = this, + accountsWithAdded = this.filterByRawID(), + ) + }.distinctUntilChanged() + + private fun AccountStatusList.filterByRawID(): List { + fun AccountStatus.filterByRawID(): List = when (this) { + is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies() + .filter { status -> status.currency.id.rawCurrencyId == currencyRawId } + } + return accountStatuses.map { accountStatus -> + AccountWithAdded( + accountStatus = accountStatus, + addedCurrency = accountStatus.filterByRawID(), + ) + } + } + + private fun buildContentState( + portfolio: PortfoliosWithThisCurrency, + allActions: Map, + addButtonState: AddButtonState, + expanded: Set>, + settings: SettingsBox, + ): MyPortfolioUM.Content { + val appCurrency = settings.appCurrency + val isBalanceHidden = settings.isBalanceHidden + val isAccountMode = settings.isAccountMode + val uiItems: MutableList = mutableListOf() + + fun toggleQuickActions(key: Pair) = expandedHolder?.update { expanded -> + val isExpand = expanded.contains(key) + if (isExpand) expanded.minus(key) else expanded.plus(key) + } + + val tokenUMConverter = PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { }, + tokenActionsHandler = tokenActionsHandler, + ) + + portfolio.portfolios.forEach { portfolioItem -> + if (portfolioItem.flattenAddedCurrency.isEmpty()) return@forEach + val userWallet = portfolioItem.userWallet + if (isAccountMode) { + uiItems.add(portfolioItem.userWallet.toWalletHeader()) + } else { + uiItems.add(portfolioItem.userWallet.toWalletPortfolioHeader()) + } + + portfolioItem.accountsWithAdded.forEach { accountWithAdded -> + if (accountWithAdded.addedCurrency.isEmpty()) return@forEach + if (isAccountMode) { + val account = accountWithAdded.accountStatus.account + uiItems.add(account.toAccountPortfolioHeader()) + } + + accountWithAdded.addedCurrency.forEach { currencyStatus -> + val actions = allActions[currencyStatus.currency]?.states + ?: emptyList() + val value = PortfolioData.CryptoCurrencyData( + userWallet = userWallet, + status = currencyStatus, + actions = actions, + ) + val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id + val isExpand = expanded.contains(expandedKey) + + val tokenItem = tokenUMConverter.convertV2( + onTokenItemClick = { wallet, status -> + toggleQuickActions(wallet.walletId to status.currency.id) + }, + value = value, + isQuickActionsShown = isExpand, + ) + uiItems.add(tokenItem) + } + } + } + + return MyPortfolioUM.Content( + items = uiItems.toImmutableList(), + buttonState = addButtonState, + onAddClick = onAddClick, + ) + } + + private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.accountId.value, + state = AccountTitleUM.Account( + prefixText = TextReference.EMPTY, + name = this.accountName.toUM().value, + icon = when (this) { + is Account.CryptoPortfolio -> this.icon.toUM() + }, + ), + ) + + private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.walletId.stringValue, + state = AccountTitleUM.Text( + title = stringReference(this.name), + ), + ) + + private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader( + id = this.walletId.stringValue, + name = stringReference(this.name), + ) + + @Suppress("LongParameterList") + @AssistedFactory + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + tokenActionsHandler: TokenActionsHandler, + buttonState: Flow, + onAddClick: () -> Unit, + ): NewMarketsPortfolioDelegate + } +} + +private data class PortfoliosWithThisCurrency( + val currencyRawId: CryptoCurrency.RawID, + val portfolios: List, +) { + + val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency } + + val flattenAddedCurrency: List = + portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten() +} + +private data class Portfolio( + val userWallet: UserWallet, + val accountStatusList: AccountStatusList, + val accountsWithAdded: List, +) { + val flattenAddedCurrency: List = + accountsWithAdded.map { it.addedCurrency }.flatten() +} + +private data class AccountWithAdded( + val addedCurrency: List, + val accountStatus: AccountStatus, +) + +private data class SettingsBox( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val isAccountMode: Boolean, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt index 2bb27f2a78..ce68f5550d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -27,6 +27,24 @@ internal class PortfolioTokenUMConverter( private val tokenActionsHandler: TokenActionsHandler, ) : Converter { + fun convertV2( + value: PortfolioData.CryptoCurrencyData, + isQuickActionsShown: Boolean, + onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit, + ): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + onItemClick = { _, status -> onTokenItemClick(value.userWallet, status) }, + ) + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = isQuickActionsShown, + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + ) + } + override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM { val tokenItemStateConverter = TokenItemStateConverter( appCurrency = appCurrency, @@ -84,7 +102,7 @@ internal class PortfolioTokenUMConverter( ) } - private fun toQuickActions(actions: List) = buildList { + fun toQuickActions(actions: List) = buildList { actions.forEach { action -> if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { when (action) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt index e829c57b6b..a0d60beb71 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt @@ -172,7 +172,7 @@ internal class TokenActionsHandler @AssistedInject constructor( router.push( AppRoute.Staking( userWalletId = cryptoCurrencyData.userWallet.walletId, - cryptoCurrencyId = cryptoCurrencyData.status.currency.id, + cryptoCurrency = cryptoCurrencyData.status.currency, yieldId = yield.id, ), ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt index 1c88861dac..582bcc4690 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt @@ -10,29 +10,43 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SmallButtonShimmer import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock -import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.markets.impl.R import com.tangem.features.markets.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider -import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.* import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState @Composable internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) { + if (state is MyPortfolioUM.Content) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing20, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32, + ) + PortfolioList(state, contentModifier) + return + } InformationBlock( modifier = modifier, contentHorizontalPadding = TangemTheme.dimens.spacing0, @@ -55,6 +69,7 @@ internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) { MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier) MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier) MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier) + is MyPortfolioUM.Content -> PortfolioList(state = state) } } @@ -119,6 +134,7 @@ private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier state.tokens.fastForEachIndexed { index, token -> key(token.tokenItemState.id) { PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), state = token, lastInList = index == state.tokens.size - 1, ) @@ -129,6 +145,111 @@ private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier TokenReceiveBottomSheet(config = state.tokenReceiveBSConfig) } +@Composable +private fun PortfolioList(state: MyPortfolioUM.Content, modifier: Modifier = Modifier) { + Column(modifier) { + key("PortfolioListHeader") { + Row( + modifier = Modifier.padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + AddButton(state = state.buttonState, onClick = state.onAddClick) + } + } + + state.items.fastForEachIndexed { index, item -> + val previousItem = state.items.getOrNull(index.dec()) + val nextItem = state.items.getOrNull(index.inc()) + val itemModifier = Modifier + .fillMaxWidth() + .getOffsetModifier(item, previousItem) + .getBackgroundModifier(item, previousItem, nextItem) + + key(item.id) { + PortfolioItem( + item = item, + modifier = itemModifier, + lastInList = index == state.items.size - 1, + ) + } + } + } +} + +@Composable +private fun Modifier.getBackgroundModifier( + item: PortfolioListItem, + previousItem: PortfolioListItem?, + nextItem: PortfolioListItem?, +): Modifier { + val color = TangemTheme.colors.background.action + val radius = 14.dp + val topRound = RoundedCornerShape(topStart = radius, topEnd = radius) + val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + val allRound = RoundedCornerShape(size = radius) + val backgroundModifier = when (item) { + is WalletHeader -> this + is PortfolioHeader -> this + .clip(topRound) + .background(color = color) + is PortfolioTokenUM -> when { + previousItem is PortfolioHeader && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + previousItem is WalletHeader && nextItem !is PortfolioTokenUM -> this + .clip(allRound) + .background(color = color) + previousItem is PortfolioTokenUM && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + else -> this.background(color = color) + } + } + return backgroundModifier +} + +private fun Modifier.getOffsetModifier(item: PortfolioListItem, previousItem: PortfolioListItem?): Modifier = when { + item is WalletHeader -> this.padding(top = 20.dp, start = 4.dp, end = 4.dp) + item is PortfolioHeader && previousItem is PortfolioTokenUM -> this.padding(top = 12.dp) + item is PortfolioHeader && previousItem == null -> this.padding(top = 20.dp) + previousItem is WalletHeader -> this.padding(top = 12.dp) + previousItem is PortfolioTokenUM -> this + else -> this +} + +@Composable +private fun PortfolioItem(item: PortfolioListItem, lastInList: Boolean, modifier: Modifier = Modifier) { + when (item) { + is PortfolioHeader -> AccountTitle( + modifier = modifier.padding( + start = 12.dp, + top = 12.dp, + bottom = 8.dp, + ), + accountTitleUM = item.state, + textStyle = TangemTheme.typography.caption1, + textColor = TangemTheme.colors.text.primary1, + ) + is PortfolioTokenUM -> PortfolioItem( + state = item, + modifier = modifier, + lastInList = lastInList, + ) + is WalletHeader -> Text( + modifier = modifier, + text = item.name.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + @Composable fun UnavailableAsset(modifier: Modifier = Modifier) { UnavailableContent( @@ -199,8 +320,7 @@ private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state TangemThemePreview { Box( modifier = Modifier - .background(TangemTheme.colors.background.tertiary) - .padding(TangemTheme.dimens.spacing8), + .background(TangemTheme.colors.background.tertiary), ) { MyPortfolio(state) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt index ac68e930d6..4f47bfddab 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt @@ -45,7 +45,6 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie TokenItem( state = tokenItemState, isBalanceHidden = state.isBalanceHidden, - modifier = Modifier.background(color = TangemTheme.colors.background.action), itemPaddingValues = PaddingValues( start = TangemTheme.dimens.spacing10, end = TangemTheme.dimens.spacing12, @@ -54,7 +53,6 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie PortfolioQuickActions( modifier = Modifier - .background(color = TangemTheme.colors.background.action) .padding( bottom = if (lastInList) { TangemTheme.dimens.spacing12 @@ -82,6 +80,7 @@ private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: } PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), state = tokenUM.copy( tokenItemState = when (tokenUM.tokenItemState) { is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/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..e4d1590e43 --- /dev/null +++ b/features/onboarding-v2/impl/detekt-baseline-debug.xml @@ -0,0 +1,143 @@ + + + + + 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:MultiWalletFinalizeModel.kt$MultiWalletFinalizeModel$private var walletHasBackupError = false + 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:MultiWalletFinalizeModel.kt$MultiWalletFinalizeModel${ it is UserWallet.Cold && it.scanResponse.primaryCard?.cardId == scanResponse.primaryCard?.cardId } + MultilineLambdaItParameter:MultiWalletFinalizeModel.kt$MultiWalletFinalizeModel${ it.requireColdWallet().copy( scanResponse = scanResponse.updateScanResponseAfterBackup(), ) } + 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(), ) + SuspendFunSwallowedCancellation:MultiWalletFinalizeModel.kt$MultiWalletFinalizeModel$runCatching + 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:MultiWalletFinalizeModel.kt$MultiWalletFinalizeModel$backupService.backupCardIds.firstOrNull()?.lastMasked() ?: "" + UseOrEmpty:MultiWalletFinalizeModel.kt$MultiWalletFinalizeModel$backupService.backupCardIds.getOrNull(cardIndex + 1) ?.lastMasked() ?: "" + 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..3fa335c815 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,8 +1,10 @@ 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 @@ -17,6 +19,7 @@ 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 @@ -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( @@ -62,6 +67,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( private val onboardingRepository: OnboardingRepository, private val walletsRepository: WalletsRepository, private val uiMessageSender: UiMessageSender, + private val backupValidator: BackupValidator, ) : Model() { private val params = paramsContainer.require() @@ -76,6 +82,8 @@ internal class MultiWalletFinalizeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onBackFlow = MutableSharedFlow() val onEvent = MutableSharedFlow() + val resetCardsModelCallbacks = ResetCardsModelCallbacks() + val startFullResetFlow = MutableSharedFlow() init { modelScope.launch { @@ -229,16 +237,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 (walletHasBackupError || !backupValidator.isValidFull(scanResponse.card)) { + startFullResetFlow.emit( + userWalletCreated.copy( + scanResponse = scanResponse.updateScanResponseAfterBackup(), + ), + ) + return@launch + } + val userWallet = when (params.parentParams.mode) { OnboardingMultiWalletComponent.Mode.Onboarding, OnboardingMultiWalletComponent.Mode.ContinueFinalize, -> { - saveWalletUseCase( + saveWalletUseCase.invoke( userWallet = userWalletCreated.copy( scanResponse = scanResponse.updateScanResponseAfterBackup(), ), @@ -254,7 +275,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( } ?: userWalletCreated - updateWalletUseCase( + updateWalletUseCase.invoke( userWalletId = userWallet.walletId, update = { it.requireColdWallet().copy( @@ -266,14 +287,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() @@ -287,7 +314,19 @@ internal class MultiWalletFinalizeModel @Inject constructor( } if (userWallet.scanResponse.cardTypesResolver.isWallet2() && userWallet.isImported) { - launch(NonCancellable) { walletsRepository.markWallet2WasCreated(userWallet.walletId) } + launch(NonCancellable) { + runCatching { + walletsRepository.markWallet2WasCreated(userWallet.walletId) + } + } + } + + if (userWallet.isMultiCurrency) { + launch(NonCancellable) { + runCatching { + walletsRepository.createWallet(userWallet.walletId) + } + } } // user wallet is fully created and saved, remove scan response from preferences @@ -321,6 +360,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 +406,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..ea9c735b45 --- /dev/null +++ b/features/onramp/impl/detekt-baseline-debug.xml @@ -0,0 +1,95 @@ + + + + + BooleanPropertyNaming:ConfirmResidencyComponent.kt$ConfirmResidencyComponent.Params$val launchSepa: Boolean + BooleanPropertyNaming:DefaultHotCryptoComponent.kt$DefaultHotCryptoComponent$val scrollableContent = when (stack.active.configuration) { OnrampAddTokenRoute.PortfolioSelector -> false OnrampAddTokenRoute.AddToken, OnrampAddTokenRoute.Empty, -> true } + BooleanPropertyNaming:OnrampAddTokenComponent.kt$OnrampAddTokenComponent.AddHotCryptoData$val availableMorePortfolio: Boolean + BooleanPropertyNaming:OnrampMainComponent.kt$OnrampMainComponent.Params$val launchSepa: Boolean + BooleanPropertyNaming:OnrampMainComponentUM.kt$BuyButtonConfig$val enabled: Boolean + BooleanPropertyNaming:ProviderListPaymentMethodUM.kt$ProviderListPaymentMethodUM$val enabled: Boolean + BooleanPropertyNaming:SelectProviderModel.kt$SelectProviderModel$val allErrorProviders = providers.all { it is OnrampProviderWithQuote.Unavailable.NotSupportedPaymentMethod } + MaxChainedCallsOnSameLine:OnrampMainComponentModel.kt$OnrampMainComponentModel$content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero() + MaxChainedCallsOnSameLine:OnrampV2MainComponentModel.kt$OnrampV2MainComponentModel$content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero() + MultilineLambdaItParameter:AvailableSwapPairsModel.kt$AvailableSwapPairsModel${ handleErrorState( cause = it, networkInfo = params.selectedStatus.value?.toLeastTokenInfo(), currencies = currencies, ) } + MultilineLambdaItParameter:AvailableSwapPairsModel.kt$AvailableSwapPairsModel${ handleErrorStateV2( cause = it, networkInfo = params.selectedStatus.value?.toLeastTokenInfo(), accountList = accountList, ) } + MultilineLambdaItParameter:AvailableSwapPairsModel.kt$AvailableSwapPairsModel${ it.currency.name.contains(other = query, ignoreCase = true) || it.currency.symbol.contains(other = query, ignoreCase = true) } + MultilineLambdaItParameter:AvailableSwapPairsModel.kt$AvailableSwapPairsModel${ it.toMutableMap().apply { put(networkInfo, state) } } + MultilineLambdaItParameter:DefaultOnrampComponent.kt$DefaultOnrampComponent${ navigation.push( OnrampChild.RedirectPage( quote = it, cryptoCurrency = params.cryptoCurrency, ), ) } + MultilineLambdaItParameter:HotCryptoModel.kt$HotCryptoModel${ Timber.e(it) closeNavigationFlow() } + MultilineLambdaItParameter:HotCryptoModel.kt$HotCryptoModel${ bottomSheetNavigation.dismiss() params.onTokenClick(it) } + MultilineLambdaItParameter:HotCryptoPortfolioDataLoader.kt$HotCryptoPortfolioDataLoader${ val account: AccountStatus.CryptoPortfolio = when (it) { is AccountStatus.CryptoPortfolio -> it } val addedHotCrypto = mapOfAddedCurrencies[account.account] ?: listOf() HotCryptoPortfolioData.Account( account = account, addedHotCrypto = addedHotCrypto, ) } + MultilineLambdaItParameter:OnrampAddToPortfolioModel.kt$OnrampAddToPortfolioModel${ Timber.e("Failed to derive public keys: $it") changeAddButtonProgressStatus(isProgress = false) } + MultilineLambdaItParameter:OnrampAddTokenModel.kt$OnrampAddTokenModel${ processError(error = it) uiState.value = um.toggleProgress(false) return@launch } + MultilineLambdaItParameter:OnrampMainComponentModel.kt$OnrampMainComponentModel${ (it as? OnrampMainComponentUM.Content)?.copy( errorNotification = null, providerBlockState = OnrampProviderBlockUM.Loading, amountBlockState = it.amountBlockState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading), ) ?: it } + MultilineLambdaItParameter:OnrampMainComponentModel.kt$OnrampMainComponentModel${ if (it is OnrampMainComponentUM.InitialLoading) { stateFactory.getReadyState(country.defaultCurrency) } else { amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } } + MultilineLambdaItParameter:OnrampMainComponentModel.kt$OnrampMainComponentModel${ it.provider.id == providerState?.providerId && it.paymentMethod.id == providerState.paymentMethod.id } + MultilineLambdaItParameter:OnrampOffersContent.kt${ SpacerH(12.dp) SecondaryButton( modifier = Modifier.fillMaxWidth(), text = it.title.resolveReference(), onClick = it.onClick, ) } + MultilineLambdaItParameter:OnrampOffersContent.kt${ Text( modifier = Modifier .background( color = TangemTheme.colors.text.warning.copy(alpha = 0.1f), shape = RoundedCornerShape(4.dp), ) .padding(horizontal = 4.dp), text = it.resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.warning, ) } + MultilineLambdaItParameter:OnrampRedirectModel.kt$OnrampRedirectModel${ latestOnrampTransaction = it // 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) } else { urlOpener.openUrl(it.redirectUrl) } } + MultilineLambdaItParameter:OnrampTokenItemStateConverterFactory.kt$OnrampTokenItemStateConverterFactory${ TokenItemState.TitleState.Content( text = stringReference(value = it.currency.name), isAvailable = false, ) } + MultilineLambdaItParameter:OnrampTokenItemStateConverterFactory.kt$OnrampTokenItemStateConverterFactory${ createSubtitleState( status = it, isAvailable = false, text = unavailableErrorText, ) } + MultilineLambdaItParameter:OnrampTokenItemStateConverterFactory.kt$OnrampTokenItemStateConverterFactory${ createSubtitleState( status = it, isAvailable = true, text = stringReference(value = it.currency.symbol), ) } + MultilineLambdaItParameter:OnrampTokenItemStateConverterFactory.kt$OnrampTokenItemStateConverterFactory${ createSubtitleState( status = it, text = stringReference(value = it.currency.symbol), isAvailable = false, ) } + MultilineLambdaItParameter:OnrampTokenListModel.kt$OnrampTokenListModel${ if (hasRestrictionForSell || isInsufficientBalanceForSell) { mapOf(false to it) } else { it.filterByAvailability() } } + MultilineLambdaItParameter:OnrampTokenListModel.kt$OnrampTokenListModel${ it.currency.name.contains(other = query, ignoreCase = true) || it.currency.symbol.contains(other = query, ignoreCase = true) } + MultilineLambdaItParameter:OnrampV2MainComponentModel.kt$OnrampV2MainComponentModel${ (it as? OnrampV2MainComponentUM.Content)?.copy( errorNotification = null, offersBlockState = OnrampOffersBlockUM.Loading, amountBlockState = it.amountBlockState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty), ) ?: it } + MultilineLambdaItParameter:OnrampV2MainComponentModel.kt$OnrampV2MainComponentModel${ when (it) { is OnrampV2MainComponentUM.Content -> { amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } is OnrampV2MainComponentUM.InitialLoading -> { stateFactory.getReadyState(country.defaultCurrency) } } } + MultilineLambdaItParameter:SelectProviderModel.kt$SelectProviderModel${ when (it) { is OnrampProviderWithQuote.Data -> it.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 else -> null } } is OnrampProviderWithQuote.Unavailable.NotSupportedPaymentMethod -> null } } + NoNameShadowing:OnrampAddTokenUiBuilder.kt$OnrampAddTokenUiBuilder$tokenToAdd + 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 + NullableBooleanCheck:HotCryptoModel.kt$HotCryptoModel$hotCryptoPortfolioData.wallet.accounts .find { it.account.accountId == accountStatus.accountId } ?.addedHotCrypto ?.none { it.currency.id.rawCurrencyId == hotCrypto.cryptoCurrency.id.rawCurrencyId } ?: false + NullableToStringCall:SwapSelectTokensController.kt$SwapSelectTokensController$${transformer::class.simpleName} + NullableToStringCall:TokenListUMController.kt$TokenListUMController$${transformer::class.simpleName} + PropertyUsedBeforeDeclaration:AllOffersModel.kt$AllOffersModel$state + PropertyUsedBeforeDeclaration:CountryListUMController.kt$CountryListUMController$_state + PropertyUsedBeforeDeclaration:CurrencyListController.kt$CurrencyListController$_state + PropertyUsedBeforeDeclaration:HotCryptoModel.kt$HotCryptoModel$_state + PropertyUsedBeforeDeclaration:OnrampAddToPortfolioModel.kt$OnrampAddToPortfolioModel$_state + PropertyUsedBeforeDeclaration:OnrampMainComponentModel.kt$OnrampMainComponentModel$_state + PropertyUsedBeforeDeclaration:OnrampMainComponentModel.kt$OnrampMainComponentModel$userCountry + PropertyUsedBeforeDeclaration:OnrampOperationModel.kt$OnrampOperationModel$_state + PropertyUsedBeforeDeclaration:OnrampSelectCountryModel.kt$OnrampSelectCountryModel$controller + PropertyUsedBeforeDeclaration:OnrampSelectCurrencyModel.kt$OnrampSelectCurrencyModel$controller + PropertyUsedBeforeDeclaration:OnrampSettingsModel.kt$OnrampSettingsModel$_state + PropertyUsedBeforeDeclaration:OnrampV2MainComponentModel.kt$OnrampV2MainComponentModel$_state + PropertyUsedBeforeDeclaration:SelectProviderModel.kt$SelectProviderModel$_state + PropertyUsedBeforeDeclaration:SwapSelectTokensController.kt$SwapSelectTokensController$_state + PropertyUsedBeforeDeclaration:SwapSelectTokensModel.kt$SwapSelectTokensModel$_fromCurrencyStatus + PropertyUsedBeforeDeclaration:TokenListUMController.kt$TokenListUMController$_state + RedundantSuspendModifier:OnrampSelectCountryModel.kt$OnrampSelectCountryModel$suspend + ReusedModifierInstance:DefaultOnrampComponent.kt$DefaultOnrampComponent$Content(modifier = modifier) + SuspendFunSwallowedCancellation:AvailableSwapPairsModel.kt$AvailableSwapPairsModel$runCatching + SuspendFunSwallowedCancellation:OnrampMainComponentModel.kt$OnrampMainComponentModel$runCatching + SuspendFunSwallowedCancellation:OnrampSuccessComponentModel.kt$OnrampSuccessComponentModel$runCatching + SuspendFunSwallowedCancellation:OnrampV2MainComponentModel.kt$OnrampV2MainComponentModel$runCatching + UnnecessaryApply:OnrampFooterContent.kt$apply { 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, ) { SpacerH(16.dp) OnrampAmountButtons(state = state.onrampAmountButtonUMState) } } } + UnnecessaryLet:AllOffersModel.kt$AllOffersModel$let { analyticsEventHandler::send } + UnnecessaryLet:HotTokenItemStateConverter.kt$HotTokenItemStateConverter$let { onItemClick -> { onItemClick(it, value) } } + UnnecessaryLet:OnrampButtonComponent.kt$let { state.onLinkClick(privacyPolicyLink) } + UnnecessaryLet:OnrampButtonComponent.kt$let { state.onLinkClick(termsOfUseLink) } + UnnecessaryLet:OnrampTokenListModel.kt$OnrampTokenListModel$let(::resourceReference) + UnnecessaryLet:OnrampTokenListUMExt.kt$let(::add) + UnnecessaryLet:OnrampV2MainComponentModel.kt$OnrampV2MainComponentModel$let(analyticsEventHandler::send) + UnnecessaryLet:SetNothingToFoundStateTransformer.kt$SetNothingToFoundStateTransformer$let(::add) + UnnecessaryLet:SetNothingToFoundStateTransformerV2.kt$SetNothingToFoundStateTransformerV2$let(::add) + UnsafeCallOnNullableType:DefaultHotCryptoComponent.kt$DefaultHotCryptoComponent$addTokenComponent!! + UnsafeCallOnNullableType:DefaultHotCryptoComponent.kt$DefaultHotCryptoComponent$model.portfolioFetcher!! + UnsafeCallOnNullableType:DefaultHotCryptoComponent.kt$DefaultHotCryptoComponent$portfolioSelectorComponent!! + UseEmptyCounterpart:HotCryptoPortfolioDataLoader.kt$HotCryptoPortfolioDataLoader$listOf() + UseEmptyCounterpart:SelectProviderPreviewData.kt$SelectProviderPreviewData$listOf() + UseOrEmpty:AvailableSwapPairsModel.kt$AvailableSwapPairsModel$selectedStatus?.currency?.name?.capitalize() ?: "" + UseOrEmpty:HotCryptoPortfolioDataLoader.kt$HotCryptoPortfolioDataLoader$getAccountCurrencyStatusUseCase .invokeSync(userWalletId, hotCryptoCurrencies) .getOrNull() ?: emptyMap() + UseOrEmpty:HotCryptoPortfolioDataLoader.kt$HotCryptoPortfolioDataLoader$mapOfAddedCurrencies[account.account] ?: listOf() + UselessCallOnNotNull:OnrampAddToPortfolioModel.kt$OnrampAddToPortfolioModel$listOfNotNull(params.cryptoCurrency) + VarCouldBeVal:AvailableSwapPairsModel.kt$AvailableSwapPairsModel$private var params: AvailableSwapPairsComponent.Params = paramsContainer.require() + VarCouldBeVal:OnrampSuccessComponentModel.kt$OnrampSuccessComponentModel$private var expressTxStatusTaskScheduler = SingleTaskScheduler<Unit>() + VarCouldBeVal:SwapSelectTokensModel.kt$SwapSelectTokensModel$private var account: Account.CryptoPortfolio? = null + + diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt index 351cd1a5aa..6035f80287 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt @@ -4,6 +4,7 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent @@ -14,6 +15,7 @@ import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.MINUS +import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal @@ -23,42 +25,64 @@ internal class AllOffersStateFactory( private val allOffersIntents: AllOffersIntents, ) { - fun getLoadedPaymentsState(methodGroups: List): AllOffersStateUM { + fun getLoadedPaymentsState(methodGroups: List, currencyCode: String): AllOffersStateUM { return AllOffersStateUM.Content( - methods = methodGroups.map { methodGroup -> - AllOffersPaymentMethodUM( - offers = mapOffersToUM(methodGroup.offers).toPersistentList(), - methodConfig = OnrampPaymentMethodConfig( - method = methodGroup.paymentMethod, - onClick = { allOffersIntents.onPaymentMethodClicked(methodGroup.paymentMethod.id) }, - ), - diff = methodGroup - .bestRateOffer - ?.rateDif - ?.takeIf { it > BigDecimal.ZERO } - ?.let { diff -> - stringReference("$MINUS${diff.format { percent() }}") - }, - rate = methodGroup.bestRateOffer?.let { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> quote.toAmount.value.format { - crypto( - symbol = quote.toAmount.symbol, - decimals = quote.toAmount.decimals, - ) - } - else -> "" - } - } ?: "", - providersCount = methodGroup.providerCount, - isBestRate = methodGroup.isBestPaymentMethod, - ) + methods = methodGroups.map { group -> + createPaymentMethodUM(group, currencyCode) }.toPersistentList(), - currentMethod = null, + currentMethod = replaceOffersForCurrentMethod(methodGroups, currencyCode), onBackClicked = { allOffersIntents.onBackClicked() }, ) } + private fun createPaymentMethodUM( + methodGroup: OnrampPaymentMethodGroup, + currencyCode: String, + ): AllOffersPaymentMethodUM { + return AllOffersPaymentMethodUM( + offers = mapOffersToUM(methodGroup.offers, currencyCode).toPersistentList(), + methodConfig = createMethodConfig(methodGroup.paymentMethod), + diff = formatRateDiff(methodGroup.bestRateOffer?.rateDif), + rate = formatBestRate(methodGroup.bestRateOffer, currencyCode), + providersCount = methodGroup.providerCount, + isBestRate = methodGroup.isBestPaymentMethod, + paymentMethodStatus = methodGroup.methodStatus, + ) + } + + private fun createMethodConfig(paymentMethod: OnrampPaymentMethod): OnrampPaymentMethodConfig { + return OnrampPaymentMethodConfig( + method = paymentMethod, + onClick = { allOffersIntents.onPaymentMethodClicked(paymentMethod.id) }, + ) + } + + private fun formatRateDiff(rateDif: BigDecimal?) = rateDif + ?.takeIf { it > BigDecimal.ZERO } + ?.let { diff -> stringReference("$MINUS${diff.format { percent() }}") } + + private fun formatBestRate(bestRateOffer: OnrampOffer?, currencyCode: String): String { + return when (val quote = bestRateOffer?.quote) { + is OnrampQuote.Data -> formatCryptoAmount(quote.toAmount) + is OnrampQuote.AmountError -> formatRequiredAmount(quote, currencyCode) + is OnrampQuote.Error, + null, + -> "" + } + } + + private fun formatCryptoAmount(amount: OnrampAmount): String { + return amount.value.format { + crypto(symbol = amount.symbol, decimals = amount.decimals) + } + } + + private fun formatRequiredAmount(quote: OnrampQuote.AmountError, currencyCode: String): String { + return quote.error.requiredAmount.format { + fiat(fiatCurrencySymbol = quote.fromAmount.symbol, fiatCurrencyCode = currencyCode) + } + } + fun getPaymentsState(): AllOffersStateUM { return when (val currentState = currentStateProvider.invoke()) { is AllOffersStateUM.Content -> { @@ -72,34 +96,36 @@ internal class AllOffersStateFactory( } fun getOnrampErrorState(onrampError: OnrampError): AllOffersStateUM { - return when (onrampError) { - is OnrampError.DataError -> getErrorState( - errorCode = onrampError.code, - onRefresh = allOffersIntents::onRefresh, - ) - OnrampError.PairsNotFound, - is OnrampError.DomainError, - -> getErrorState(onRefresh = allOffersIntents::onRefresh) - is OnrampError.AmountError.TooBigError, - is OnrampError.AmountError.TooSmallError, - OnrampError.RedirectError.VerificationFailed, - OnrampError.RedirectError.WrongRequestId, - -> currentStateProvider() + return if (shouldShowErrorState(onrampError)) { + createErrorState(errorCode = (onrampError as? OnrampError.DataError)?.code) + } else { + currentStateProvider() } } - private fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): AllOffersStateUM { - val state = currentStateProvider() - return when (state) { + private fun shouldShowErrorState(error: OnrampError): Boolean { + return when (error) { + is OnrampError.DataError, + OnrampError.PairsNotFound, + is OnrampError.DomainError, + -> true + is OnrampError.AmountError, + is OnrampError.RedirectError, + -> false + } + } + + private fun createErrorState(errorCode: String? = null): AllOffersStateUM { + return when (currentStateProvider()) { is AllOffersStateUM.Content, AllOffersStateUM.Loading, -> AllOffersStateUM.Error( errorNotification = NotificationUM.Warning.OnrampErrorNotification( errorCode = errorCode, - onRefresh = onRefresh, + onRefresh = allOffersIntents::onRefresh, ), ) - is AllOffersStateUM.Error -> state + is AllOffersStateUM.Error -> currentStateProvider() } } @@ -108,52 +134,75 @@ internal class AllOffersStateFactory( OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest + OnrampOfferAdvantages.GreatRate -> OnrampOfferAdvantagesUM.GreatRate } } - private fun mapOffersToUM(offers: List): List { - return buildList { - offers.forEach { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> { - add( - OnrampOfferUM( - category = OnrampOfferCategoryUM.Recommended, - advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), - paymentMethod = quote.paymentMethod, - providerId = quote.provider.id, - providerName = quote.provider.info.name, - rate = quote.toAmount.value.format { - crypto( - symbol = quote.toAmount.symbol, - decimals = quote.toAmount.decimals, - ) - }, - diff = offer - .rateDif - ?.takeIf { it > BigDecimal.ZERO } - ?.let { diff -> - stringReference("$MINUS${diff.format { percent() }}") - }, - onBuyClicked = { - allOffersIntents.onBuyClick( - quote = OnrampProviderWithQuote.Data( - provider = quote.provider, - paymentMethod = quote.paymentMethod, - toAmount = quote.toAmount, - fromAmount = quote.fromAmount, - ), - onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), - ) - }, - ), - ) - } - is OnrampQuote.AmountError, - is OnrampQuote.Error, - -> Unit - } + private fun mapOffersToUM(offers: List, currencyCode: String): List { + return offers.mapNotNull { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> createDataOfferUM(quote, offer) + is OnrampQuote.AmountError -> createAmountErrorOfferUM(quote, offer, currencyCode) + is OnrampQuote.Error -> null } } } + + private fun createDataOfferUM(quote: OnrampQuote.Data, offer: OnrampOffer): OnrampOfferUM { + return OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), + paymentMethod = quote.paymentMethod, + providerName = quote.provider.info.name, + rate = formatCryptoAmount(quote.toAmount), + diff = formatRateDiff(offer.rateDif), + onBuyClicked = { + allOffersIntents.onBuyClick( + quote = OnrampProviderWithQuote.Data( + provider = quote.provider, + paymentMethod = quote.paymentMethod, + toAmount = quote.toAmount, + fromAmount = quote.fromAmount, + ), + onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), + ) + }, + ) + } + + private fun createAmountErrorOfferUM( + quote: OnrampQuote.AmountError, + offer: OnrampOffer, + currencyCode: String, + ): OnrampOfferUM { + val advantages = when (quote.error) { + is OnrampError.AmountError.TooSmallError -> OnrampOfferAdvantagesUM.Unavailable.MinAmount + is OnrampError.AmountError.TooBigError -> OnrampOfferAdvantagesUM.Unavailable.MaxAmount + } + + return OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = advantages, + paymentMethod = quote.paymentMethod, + providerName = quote.provider.info.name, + rate = formatRequiredAmount(quote, currencyCode), + diff = formatRateDiff(offer.rateDif), + onBuyClicked = {}, + ) + } + + private fun replaceOffersForCurrentMethod( + methodGroups: List, + currencyCode: String, + ): AllOffersPaymentMethodUM? { + val currentMethod = (currentStateProvider() as? AllOffersStateUM.Content)?.currentMethod ?: return null + + val updateForCurrentMethod = methodGroups.find { it.paymentMethod.id == currentMethod.methodConfig.method.id } + + return updateForCurrentMethod?.let { updatedCurrentMethod -> + currentMethod.copy( + offers = mapOffersToUM(updatedCurrentMethod.offers, currencyCode).toImmutableList(), + ) + } + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt index 834bc396b3..8e1847afc5 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.onramp.alloffers.entity import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodStatus import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM import kotlinx.collections.immutable.ImmutableList @@ -26,6 +27,7 @@ internal data class AllOffersPaymentMethodUM( val rate: String, val providersCount: Int, val isBestRate: Boolean, + val paymentMethodStatus: PaymentMethodStatus, ) internal data class OnrampPaymentMethodConfig( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt index 29414b13c0..12b43915ad 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -74,6 +74,7 @@ internal class AllOffersModel @Inject constructor( providerName = quote.provider.info.name, paymentMethodName = quote.paymentMethod.name, )?.let { analyticsEventHandler::send } + dismiss() params.openRedirectPage(quote) } @@ -95,7 +96,12 @@ internal class AllOffersModel @Inject constructor( maybeOffers.fold( ifLeft = ::handleOnrampError, ifRight = { offersGroup -> - _state.update { stateFactory.getLoadedPaymentsState(offersGroup) } + _state.update { + stateFactory.getLoadedPaymentsState( + methodGroups = offersGroup, + currencyCode = params.amountCurrencyCode, + ) + } }, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt index f60660546a..e59a517b99 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt @@ -26,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodStatus import com.tangem.domain.onramp.model.PaymentMethodType import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM @@ -204,7 +205,6 @@ private fun AllOffersContentSheetPaymentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId1", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -219,7 +219,6 @@ private fun AllOffersContentSheetPaymentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId2", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -239,6 +238,7 @@ private fun AllOffersContentSheetPaymentPreview() { rate = "0,0245334 BTC", providersCount = 2, isBestRate = true, + paymentMethodStatus = PaymentMethodStatus.Available, ) TangemThemePreview { @@ -270,7 +270,6 @@ private fun AllOffersContentSheetOffersPreview() { "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId1", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -286,7 +285,6 @@ private fun AllOffersContentSheetOffersPreview() { "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId2", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -306,6 +304,7 @@ private fun AllOffersContentSheetOffersPreview() { rate = "0,0245334 BTC", providersCount = 2, isBestRate = true, + paymentMethodStatus = PaymentMethodStatus.Available, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt index 7c16f78f2e..46ca3d4017 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt @@ -28,6 +28,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SelectPaymentMethodBottomSheetTestTags import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodStatus import com.tangem.domain.onramp.model.PaymentMethodType import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig @@ -89,21 +90,59 @@ private fun PaymentMethod(methodUM: AllOffersPaymentMethodUM, modifier: Modifier SpacerW(12.dp) Column { - PaymentMethodInfoBlock( - paymentMethodName = methodUM.methodConfig.method.name, - rate = methodUM.rate, - diff = methodUM.diff, - isBestRate = methodUM.isBestRate, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - ProvidersCountBlockInfo(providersCount = methodUM.providersCount) - SpacerW(8.dp) - TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed()) + when (methodUM.paymentMethodStatus) { + PaymentMethodStatus.Available -> { + PaymentMethodInfoBlock( + paymentMethodName = methodUM.methodConfig.method.name, + rate = methodUM.rate, + diff = methodUM.diff, + isBestRate = methodUM.isBestRate, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + ProvidersCountBlockInfo(providersCount = methodUM.providersCount) + SpacerW(8.dp) + TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed()) + } + } + is PaymentMethodStatus.Unavailable -> { + UnavailablePaymentMethodInfoBlock( + paymentMethodName = methodUM.methodConfig.method.name, + errorAmount = methodUM.rate, + unavailableStatus = methodUM.paymentMethodStatus, + ) + } } } } } +@Composable +private fun UnavailablePaymentMethodInfoBlock( + paymentMethodName: String, + errorAmount: String, + unavailableStatus: PaymentMethodStatus.Unavailable, +) { + Column(modifier = Modifier.padding(bottom = 14.dp)) { + Text( + text = paymentMethodName, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.PAYMENT_METHOD_NAME), + ) + SpacerH(2.dp) + val messageResId = when (unavailableStatus) { + is PaymentMethodStatus.Unavailable.MinAmount -> R.string.onramp_provider_min_amount + is PaymentMethodStatus.Unavailable.MaxAmount -> R.string.onramp_provider_max_amount + } + Text( + text = stringResourceSafe(messageResId, errorAmount), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.UP_TO_TEXT), + ) + } +} + @Composable private fun PaymentMethodInfoBlock( paymentMethodName: String, @@ -234,7 +273,6 @@ private fun PaymentMethodsContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId1", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -249,7 +287,6 @@ private fun PaymentMethodsContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId2", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -269,6 +306,7 @@ private fun PaymentMethodsContentPreview() { rate = "0,0245334 BTC", providersCount = 2, isBestRate = true, + paymentMethodStatus = PaymentMethodStatus.Available, ) TangemThemePreview { PaymentMethodsContent(persistentListOf(method)) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt index f284662276..852c1ae304 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,42 @@ package com.tangem.features.onramp.hottokens -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +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.* 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 +45,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 = 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 +86,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 +103,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 scrollableContent = when (stack.active.configuration) { + OnrampAddTokenRoute.PortfolioSelector -> false + OnrampAddTokenRoute.AddToken, + OnrampAddTokenRoute.Empty, + -> 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) { + 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 +217,12 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( } } + private fun contentChild(config: OnrampAddTokenRoute): ComposableContentComponent = when (config) { + OnrampAddTokenRoute.AddToken -> addTokenComponent!! + OnrampAddTokenRoute.PortfolioSelector -> 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/model/HotCryptoModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt index f4a8cdc2eb..3572d99f56 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 portfolioFetcher: PortfolioFetcher? + val bottomSheetNavigationV2 = StackNavigation() + private val addHotCryptoJob = JobHolder() + val hotCryptoToAddDataFlow: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val state: StateFlow get() = _state private val _state = 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,6 +163,69 @@ 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, + availableMorePortfolio = 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, + availableMorePortfolio = 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 { + Timber.e(it) + closeNavigationFlow() + } + .launchIn(modelScope) + .saveIn(addHotCryptoJob) + } + private fun onSuccessAdding(id: CryptoCurrency.ID) { modelScope.launch { getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( @@ -114,4 +239,31 @@ internal class HotCryptoModel @Inject constructor( .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 } + ?: false + 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..b47d4f39ee --- /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() + ?: emptyMap() + val accountsWithHotCrypto = walletAccounts.accountStatuses.map { + val account: AccountStatus.CryptoPortfolio = when (it) { + is AccountStatus.CryptoPortfolio -> it + } + val addedHotCrypto = mapOfAddedCurrencies[account.account] ?: listOf() + 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..2e79cd6137 --- /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 availableMorePortfolio: 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..48def24fab --- /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.availableMorePortfolio, + 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 tokenToAdd = TokenItemState.Content( + id = currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(currency), + titleState = TokenItemState.TitleState.Content(stringReference(currency.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return AddTokenUM( + tokenToAdd = tokenToAdd, + network = networkUM, + portfolio = portfolioUM, + button = button, + ) + } + + companion object { + + fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy( + button = this.button.copy(showProgress = showProgress), + ) + } +} \ No newline at end of file diff --git a/features/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..e11a0780e7 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 @@ -72,7 +72,6 @@ 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") 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..4d76738d34 --- /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 { + processError(error = it) + 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/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/mainv2/DefaultOnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt index fe65d540a7..104652d0a9 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( @@ -67,7 +64,10 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor( cryptoCurrency = params.cryptoCurrency, country = config.country, launchSepa = false, - onDismiss = { model.bottomSheetNavigation.dismiss() }, + onDismiss = { + model.bottomSheetNavigation.dismiss() + model.handleOnrampAvailable() + }, ), ) is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt index 54ea1f1451..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..b73709d3c0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -1,6 +1,5 @@ package com.tangem.features.onramp.mainv2.model -import androidx.compose.runtime.mutableStateOf import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -15,7 +14,6 @@ import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.onramp.main.entity.OnrampLastUpdate import com.tangem.features.onramp.mainv2.OnrampV2MainComponent import com.tangem.features.onramp.mainv2.entity.* import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory @@ -42,7 +40,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( private val getOnrampCountryUseCase: GetOnrampCountryUseCase, private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase, private val fetchQuotesUseCase: OnrampFetchQuotesUseCase, - private val getOnrampQuotesUseCase: GetOnrampV2QuotesUseCase, + private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase, private val fetchPairsUseCase: OnrampFetchPairsUseCase, private val amountInputManager: InputManager, private val getOnrampOffersUseCase: GetOnrampOffersUseCase, @@ -52,8 +50,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( val params = paramsContainer.require() - private val lastUpdateState = mutableStateOf(null) - private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) { OnrampAmountButtonUMStateFactory() } @@ -65,20 +61,23 @@ internal class OnrampV2MainComponentModel @Inject constructor( ) } - private val stateFactory = OnrampV2StateFactory( - currentStateProvider = Provider { _state.value }, - cryptoCurrency = params.cryptoCurrency, - onrampIntents = this, - onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, - ) + private val stateFactory: OnrampV2StateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampV2StateFactory( + currentStateProvider = Provider { _state.value }, + cryptoCurrency = params.cryptoCurrency, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + } - private val amountStateFactory = OnrampV2AmountStateFactory( - currentStateProvider = Provider { _state.value }, - analyticsEventHandler = analyticsEventHandler, - onrampIntents = this, - cryptoCurrency = params.cryptoCurrency, - onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, - ) + private val amountStateFactory: OnrampV2AmountStateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampV2AmountStateFactory( + currentStateProvider = Provider { _state.value }, + analyticsEventHandler = analyticsEventHandler, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + } private val _state: MutableStateFlow = MutableStateFlow( value = stateFactory.getInitialState( @@ -97,7 +96,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( modelScope.launch { clearOnrampCacheUseCase() } - + startLoadingQuotes() sendScreenOpenAnalytics() checkResidenceCountry() subscribeToAmountChanges() @@ -126,7 +125,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 +138,11 @@ internal class OnrampV2MainComponentModel @Inject constructor( tokenSymbol = params.cryptoCurrency.symbol, ), ) - onrampOfferAdvantagesUM.toAnalyticsEvent( - cryptoCurrencySymbol = params.cryptoCurrency.symbol, - providerName = quote.provider.info.name, - paymentMethodName = quote.paymentMethod.name, - )?.let { analyticsEventHandler::send } + sendOfferClickEvent( + quote = quote, + onrampOfferAdvantagesUM = onrampOfferAdvantagesUM, + categoryUM = categoryUM, + ) params.openRedirectPage(quote) } @@ -160,27 +163,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()) @@ -228,24 +223,24 @@ internal class OnrampV2MainComponentModel @Inject constructor( } private fun subscribeOnOffers() = modelScope.launch { - getOnrampOffersUseCase.invoke( - userWalletId = userWallet.walletId, - cryptoCurrencyId = params.cryptoCurrency.id, - ).collectLatest { maybeOffers -> - maybeOffers.fold( - ifLeft = ::handleOnrampError, - ifRight = { offers -> - _state.update { onrampOffersStateFactory.getOnShowOffersState(offers) } - }, - ) - } + getOnrampOffersUseCase + .invoke() + .collectLatest { maybeOffers -> + maybeOffers.fold( + ifLeft = ::handleOnrampError, + ifRight = { offers -> + if (offers.isNotEmpty()) { + _state.update { onrampOffersStateFactory.getOffersState(offers) } + } + }, + ) + } } private fun subscribeToAmountChanges() = modelScope.launch { amountInputManager.query .filter(String::isNotEmpty) .collectLatest { _ -> - _state.update { amountStateFactory.getAmountSecondaryLoadingState() } startLoadingQuotes() } } @@ -288,77 +283,43 @@ 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( errorNotification = null, - onrampProviderState = OnrampV2ProvidersUM.Loading, - offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false), - amountBlockState = it.amountBlockState.copy( - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, - ), + offersBlockState = OnrampOffersBlockUM.Loading, + amountBlockState = it.amountBlockState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty), ) ?: it } startLoadingQuotes() } private suspend fun updatePairsAndQuotes() { - val state = state.value as? OnrampV2MainComponentUM.Content - - if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { - _state.update { amountStateFactory.getAmountSecondaryLoadingState() } - } fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold( ifLeft = ::handleOnrampError, ifRight = { _state.update { - if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { - return@fold - } else { - amountStateFactory.getAmountSecondaryResetState() - } + amountStateFactory.getAmountSecondaryFieldResetState() } + startLoadingQuotes() }, ) - startLoadingQuotes() } private fun handleOnrampError(onrampError: OnrampError) { @@ -366,18 +327,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( _state.update { stateFactory.getOnrampErrorState(onrampError) } } - private fun updateProvider(quote: OnrampQuote) { - lastUpdateState.value = OnrampLastUpdate( - fromAmount = quote.fromAmount, - countryCode = quote.countryCode, - paymentMethod = quote.paymentMethod, - ) - - _state.update { - amountStateFactory.getUpdatedProviderState(selectedQuote = quote) - } - } - private fun sendOnrampQuotesErrorAnalytic(quotes: List) { quotes.forEach { errorState -> when (errorState) { @@ -398,11 +347,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 +356,29 @@ internal class OnrampV2MainComponentModel @Inject constructor( ) } + private fun sendOfferClickEvent( + quote: OnrampProviderWithQuote.Data, + onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM, + categoryUM: OnrampOfferCategoryUM, + ) { + when (categoryUM) { + OnrampOfferCategoryUM.RecentlyUsed -> { + OnrampAnalyticsEvent.RecentlyBuyClicked( + tokenSymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethod = quote.paymentMethod.name, + ).let(analyticsEventHandler::send) + } + OnrampOfferCategoryUM.Recommended -> { + onrampOfferAdvantagesUM.toAnalyticsEvent( + cryptoCurrencySymbol = params.cryptoCurrency.symbol, + providerName = quote.provider.info.name, + paymentMethodName = quote.paymentMethod.name, + )?.let(analyticsEventHandler::send) + } + } + } + private companion object { const val UPDATE_DELAY = 10_000L } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt index 5668fac482..979234f572 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt @@ -14,16 +14,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.Keyboard -import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.keyboardAsState -import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM +import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM @@ -33,14 +30,12 @@ internal fun OnrampFooterContent( boxScope: BoxScope, modifier: Modifier = Modifier, ) { - val keyboardController = LocalSoftwareKeyboardController.current - boxScope.apply { AnimatedVisibility( modifier = Modifier .imePadding() .align(Alignment.BottomCenter), - visible = state.offersBlockState.isBlockVisible.not(), + visible = state.offersBlockState is OnrampOffersBlockUM.Empty, enter = slideInVertically( initialOffsetY = { it }, animationSpec = tween(durationMillis = 300), @@ -55,17 +50,6 @@ internal fun OnrampFooterContent( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - text = stringResourceSafe(id = R.string.common_continue), - onClick = { - state.continueButtonConfig.onClick() - keyboardController?.hide() - }, - enabled = state.continueButtonConfig.enabled, - ) SpacerH(16.dp) OnrampAmountButtons(state = state.onrampAmountButtonUMState) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt index cf0d2ca599..1d5565f6a7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt @@ -73,7 +73,8 @@ private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifi Column( modifier = modifier .fillMaxWidth() - .wrapContentHeight(), + .wrapContentHeight() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { OnrampAmountContentLoading() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt index c39c79a8da..68b2966b21 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}") { @@ -100,6 +89,15 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { } } } + 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,7 +209,7 @@ private fun RateBlock(rate: String, diff: TextReference?) { Text( text = rate, style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, + color = if (isOfferUnavailable) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.primary1, modifier = Modifier.testTag(OnrampOffersBlockTestTags.OFFER_TOKEN_AMOUNT), ) diff?.let { @@ -314,7 +326,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 +342,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 +376,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 +385,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 +393,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 +414,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/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/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/detekt-baseline-debug.xml b/features/referral/data/detekt-baseline-debug.xml new file mode 100644 index 0000000000..c6103ddaf9 --- /dev/null +++ b/features/referral/data/detekt-baseline-debug.xml @@ -0,0 +1,7 @@ + + + + + MultilineLambdaItParameter:ReferralConverter.kt$ExpectedAwardsConverter${ ExpectedAward( paymentDate = it.paymentDate.toDateTimeAtStartOfDay().millis.toDateFormat(), amount = "${it.amount} ${it.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..6db3d31efa 100644 --- a/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt +++ b/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt @@ -9,6 +9,7 @@ import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.StartReferralBody import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.referral.converters.ReferralConverter @@ -20,7 +21,6 @@ import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -@Suppress("LongParameterList") internal class ReferralRepositoryImpl @Inject constructor( private val referralApi: TangemTechApi, private val referralConverter: ReferralConverter, @@ -74,7 +74,11 @@ internal class ReferralRepositoryImpl @Inject constructor( } } - override suspend fun getCryptoCurrency(userWalletId: UserWalletId, tokenData: TokenData): CryptoCurrency? { + override suspend fun getCryptoCurrency( + userWalletId: UserWalletId, + tokenData: TokenData, + accountIndex: DerivationIndex?, + ): CryptoCurrency? { val userWallet = withContext(coroutineDispatcher.io) { userWalletsStore.getSyncOrNull(userWalletId) ?: error("Wallet $userWalletId not found") } @@ -97,12 +101,14 @@ internal class ReferralRepositoryImpl @Inject constructor( blockchain = blockchain, extraDerivationPath = null, userWallet = userWallet, + accountIndex = accountIndex, ) } else { cryptoCurrencyFactory.createCoin( blockchain = blockchain, extraDerivationPath = null, userWallet = userWallet, + accountIndex = accountIndex, ) } } diff --git a/features/referral/domain/build.gradle.kts b/features/referral/domain/build.gradle.kts index 06c587e043..b9bec0d9f3 100644 --- a/features/referral/domain/build.gradle.kts +++ b/features/referral/domain/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.libs.crypto) /** Domain modules */ + implementation(projects.domain.account.status) implementation(projects.domain.card) implementation(projects.domain.models) implementation(projects.domain.tokens) diff --git a/features/referral/domain/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/detekt-baseline-debug.xml b/features/stories/impl/detekt-baseline-debug.xml new file mode 100644 index 0000000000..15299582b8 --- /dev/null +++ b/features/stories/impl/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + PropertyUsedBeforeDeclaration:StoriesModel.kt$StoriesModel$_state + UseEmptyCounterpart:StoriesEvents.kt$StoriesEvents$mapOf() + + 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/detekt-baseline-debug.xml b/features/swap/domain/models/detekt-baseline-debug.xml new file mode 100644 index 0000000000..04b765b070 --- /dev/null +++ b/features/swap/domain/models/detekt-baseline-debug.xml @@ -0,0 +1,8 @@ + + + + + NamedArguments:TokensDataStateExpress.kt$TokensDataStateExpress.Companion$CurrenciesGroup(emptyList(), emptyList(), emptyList(), false) + NullableBooleanCheck:ExchangeStatus.kt$ExchangeStatusModel$createdAt?.isBefore(DateTime.now().minusMinutes(FIFTEEN_MINUTES)) ?: false + + diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt index 94742cbac9..f25e4b1c1a 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain.models.domain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal @@ -12,6 +13,8 @@ data class SavedSwapTransactionListModel( val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, val toCryptoCurrency: CryptoCurrency, + val fromAccount: Account.CryptoPortfolio?, + val toAccount: Account.CryptoPortfolio?, val transactions: List, ) diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index e8e10f7165..7c76557067 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.domain.models.ui import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.models.ExpressDataError @@ -79,6 +80,7 @@ data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, val cryptoCurrencyStatus: CryptoCurrencyStatus, + val account: Account.CryptoPortfolio?, ) data class RequestApproveStateData( diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt index 6e4041dee5..f67c4a5801 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -1,5 +1,7 @@ package com.tangem.feature.swap.domain.models.ui +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo import com.tangem.feature.swap.domain.models.domain.SwapProvider @@ -9,12 +11,11 @@ data class TokensDataStateExpress( val allProviders: List, ) { companion object { - val EMPTY = - TokensDataStateExpress( - fromGroup = CurrenciesGroup(emptyList(), emptyList(), false), - toGroup = CurrenciesGroup(emptyList(), emptyList(), false), - allProviders = emptyList(), - ) + val EMPTY = TokensDataStateExpress( + fromGroup = CurrenciesGroup(emptyList(), emptyList(), emptyList(), false), + toGroup = CurrenciesGroup(emptyList(), emptyList(), emptyList(), false), + allProviders = emptyList(), + ) } } @@ -29,5 +30,18 @@ fun TokensDataStateExpress.getGroupWithReverse(isReverseFromTo: Boolean): Curren data class CurrenciesGroup( val available: List, val unavailable: List, + val accountCurrencyList: List, val isAfterSearch: Boolean, +) + +data class AccountSwapAvailability( + val account: Account.CryptoPortfolio, + val currencyList: List, +) + +data class AccountSwapCurrency( + val isAvailable: Boolean, + val account: Account.CryptoPortfolio, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val providers: List, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt index b71ed02569..a9764ab530 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt @@ -3,8 +3,10 @@ package com.tangem.feature.swap.domain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse +import com.tangem.utils.extensions.orZero import java.math.BigDecimal internal class DefaultInitialToCurrencyResolver( @@ -27,10 +29,40 @@ internal class DefaultInitialToCurrencyResolver( } } + override suspend fun tryGetFromCacheV2( + userWallet: UserWallet, + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? { + val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null + + return if (id != initialCryptoCurrency.id.value) { + val group = state.getGroupWithReverse(isReverseFromTo) + + group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id } + } + } else { + null + } + } + override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus? { val group = state.getGroupWithReverse(isReverseFromTo) return group.available.maxByOrNull { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }?.currencyStatus } + + override fun tryGetWithMaxAmountV2(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? { + val group = state.getGroupWithReverse(isReverseFromTo) + return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.maxByOrNull { swapAccountCurrency -> + swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount + .takeIf { swapAccountCurrency.isAvailable } + .orZero() + } + } + } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt index c62a113adc..7759a61e98 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress interface InitialToCurrencyResolver { @@ -15,4 +16,13 @@ interface InitialToCurrencyResolver { ): CryptoCurrencyStatus? fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus? + + suspend fun tryGetFromCacheV2( + userWallet: UserWallet, + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? + + fun tryGetWithMaxAmountV2(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 97a8cfc718..09dbef0286 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.express.models.ExpressOperationType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId @@ -29,16 +30,23 @@ interface SwapInteractor { * Find best quote for given tokens to swap * under the hood calls different methods to receive data, depends on permission for given token * - * @param fromToken [Currency] from which want to swap - * @param toToken [Currency] that receive after swap - * @param amountToSwap amount you want to swap - * @param selectedFee selected fee to swap + * @param fromToken token from which want to swap + * @param fromAccount account from which swap will be made + * @param toToken token that receive after swap + * @param toAccount account to which receive token after swap + * @param providers list of providers to find quote + * @param amountToSwap amount you want to swap + * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) + * @param selectedFee selected fee to swap * @return */ + @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -63,6 +71,8 @@ interface SwapInteractor { swapData: SwapDataModel?, currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, @@ -90,6 +100,19 @@ interface SwapInteractor { isReverseFromTo: Boolean, ): CryptoCurrencyStatus? + /** + * Returns initial currency to swap as AccountSwapCurrency + * + * @param initialCryptoCurrency initial currency selected to swap + * @param state current tokens data state + * @param isReverseFromTo flag indicating the direction of the swap + */ + suspend fun getInitialCurrencyToSwapV2( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? + fun getNativeToken(networkId: String): CryptoCurrency interface Factory { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index bbebf6f219..cab225f602 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -16,12 +16,16 @@ import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -87,6 +91,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, private val amountFormatter: AmountFormatter, private val rampStateManager: RampStateManager, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val accountsFeatureToggles: AccountsFeatureToggles, @Assisted private val userWalletId: UserWalletId, ) : SwapInteractor { @@ -102,7 +108,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId) + return if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyTokensDataState(currency) + } else { + getCurrencyTokensDataState(currency) + } + } + + private suspend fun getCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { + val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase + .invokeMultiWalletSync(userWalletId) .getOrElse { emptyList() } val walletCurrencyStatusesExceptInitial = walletCurrencyStatuses @@ -146,6 +161,65 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { + val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(userWalletId), + )?.accountStatuses.orEmpty() + + val walletAccountCurrencyStatusesExceptInitial = walletAccountCurrencyStatuses + .mapNotNull { accountStatus -> + val filteredCurrencies = accountStatus.flattenCurrencies().filter { + val currencyFilter = it.currency.network.backendId != currency.network.backendId || + it.currency.getContractAddress() != currency.getContractAddress() + + val statusFilter = + it.value is CryptoCurrencyStatus.Loaded || it.value is CryptoCurrencyStatus.NoAccount + val notCustomTokenFilter = !it.currency.isCustom + + statusFilter && currencyFilter && notCustomTokenFilter + } + + if (filteredCurrencies.isNotEmpty()) { + accountStatus.account to filteredCurrencies + } else { + null + } + }.toMap() + + if (walletAccountCurrencyStatusesExceptInitial.isEmpty()) { + return TokensDataStateExpress.EMPTY + } + + val pairsLeast = getPairs( + userWallet = userWallet, + initialCurrency = LeastTokenInfo( + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = currency.network.backendId, + ), + currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus -> + accountStatus.value.map { it.currency } + }, + ) + + return TokensDataStateExpress( + fromGroup = getToCurrenciesGroupV2( + currency = currency, + leastPairs = pairsLeast.pairs, + cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.to }, + tokenInfoForAvailable = { it.from }, + ), + toGroup = getToCurrenciesGroupV2( + currency = currency, + leastPairs = pairsLeast.pairs, + cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.from }, + tokenInfoForAvailable = { it.to }, + ), + allProviders = pairsLeast.allProviders, + ) + } + private suspend fun getToCurrenciesGroup( currency: CryptoCurrency, leastPairs: List, @@ -174,6 +248,49 @@ internal class SwapInteractorImpl @AssistedInject constructor( return CurrenciesGroup( available = availableCryptoCurrencies, unavailable = unavailableCryptoCurrencies.map { CryptoCurrencySwapInfo(it, emptyList()) }, + accountCurrencyList = emptyList(), + isAfterSearch = false, + ) + } + + private suspend fun getToCurrenciesGroupV2( + currency: CryptoCurrency, + leastPairs: List, + cryptoCurrenciesList: Map>, + tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo, + tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, + ): CurrenciesGroup { + val filteredPairs = leastPairs.filter { + tokenInfoForFilter(it).contractAddress == currency.getContractAddress() && + tokenInfoForFilter(it).network == currency.network.backendId + } + + val accountCurrencyList = cryptoCurrenciesList.mapNotNull { (account, currencyStatusList) -> + val account = account as? Account.CryptoPortfolio ?: return@mapNotNull null + + AccountSwapAvailability( + account = account, + currencyList = currencyStatusList.map { currencyStatus -> + val providers = findProvidersForPair( + cryptoCurrencyStatuses = currencyStatus, + swapPairsLeastList = filteredPairs, + tokenInfoForAvailable = tokenInfoForAvailable, + ) + val isUnavailable = providers.isNullOrEmpty() + AccountSwapCurrency( + isAvailable = !isUnavailable, + account = account, + cryptoCurrencyStatus = currencyStatus, + providers = providers.orEmpty(), + ) + }, + ) + } + + return CurrenciesGroup( + available = emptyList(), + unavailable = emptyList(), + accountCurrencyList = accountCurrencyList, isAfterSearch = false, ) } @@ -257,7 +374,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( override suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -288,7 +407,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( manageDexSolana( networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, provider = provider, selectedFee = selectedFee, amount = amount, @@ -299,7 +420,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( manageDex( networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, provider = provider, selectedFee = selectedFee, amount = amount, @@ -312,7 +435,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( manageCex( networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, provider = provider, amount = amount, reduceBalanceBy = reduceBalanceBy, @@ -327,7 +452,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDex( networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, selectedFee: FeeType, amount: SwapAmount, @@ -338,6 +465,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return provider to produceDexSwapDataError( error = ExpressDataError.DexActiveSupplyError, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) } @@ -374,7 +502,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, amount = amount, selectedFee = selectedFee, expressOperationType = expressOperationType, @@ -385,7 +515,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel = maybeQuotes, amount = amount, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, @@ -400,7 +532,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDexSolana( networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, selectedFee: FeeType, amount: SwapAmount, @@ -425,7 +559,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, amount = amount, selectedFee = selectedFee, expressOperationType = expressOperationType, @@ -436,7 +572,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel = maybeQuotes, amount = amount, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, networkId = networkId, isAllowedToSpend = true, isBalanceWithoutFeeEnough = false, @@ -451,7 +589,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageCex( networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -463,7 +603,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, fromTokenStatus = fromToken, + fromAccount = fromAccount, toTokenStatus = toToken, + toAccount = toAccount, isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, @@ -572,6 +714,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel?, currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, @@ -607,6 +751,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( onSwapCex( currencyToSend = currencyToSend, currencyToGet = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, amount = amountToSwapWithFee, txFee = fee, swapProvider = swapProvider, @@ -622,6 +768,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, amountToSwap = amountToSwap, ) } else { @@ -631,6 +779,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, txFee = fee, amountToSwap = amountToSwap, ) @@ -686,6 +836,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, txFee: TxFee, ): SwapTransactionState { @@ -714,6 +866,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, derivationPath = derivationPath, txData = txData, @@ -727,6 +881,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amountToSwap: String, ): SwapTransactionState { val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX @@ -743,6 +899,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, derivationPath = derivationPath, txData = compiledTransaction, @@ -756,6 +914,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, derivationPath: String?, txData: TransactionData, @@ -782,6 +942,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( storeSwapTransaction( currencyToSend = currencyToSendStatus, currencyToGet = currencyToGetStatus, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, swapProvider = provider, swapDataModel = swapData, @@ -820,6 +982,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun onSwapCex( currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, txFee: TxFee, swapProvider: SwapProvider, @@ -891,6 +1055,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( storeSwapTransaction( currencyToSend = currencyToSend, currencyToGet = currencyToGet, + fromAccount = fromAccount, + toAccount = toAccount, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -924,6 +1090,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun storeSwapTransaction( currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -936,6 +1104,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( userWalletId = userWalletId, fromCryptoCurrency = currencyToSend.currency, toCryptoCurrency = currencyToGet.currency, + fromAccount = fromAccount, + toAccount = toAccount, transaction = SavedSwapTransactionModel( txId = swapDataModel.transaction.txId, provider = swapProvider, @@ -976,6 +1146,17 @@ internal class SwapInteractorImpl @AssistedInject constructor( ?: group.available.firstOrNull()?.currencyStatus } + override suspend fun getInitialCurrencyToSwapV2( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? { + val group = state.getGroupWithReverse(isReverseFromTo) + return initialToCurrencyResolver.tryGetFromCacheV2(userWallet, initialCryptoCurrency, state, isReverseFromTo) + ?: initialToCurrencyResolver.tryGetWithMaxAmountV2(state, isReverseFromTo) + ?: group.accountCurrencyList.firstNotNullOfOrNull { it.currencyList.firstOrNull { it.isAvailable } } + } + override fun getNativeToken(networkId: String): CryptoCurrency { return repository.getNativeTokenForNetwork(networkId) } @@ -1020,7 +1201,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, fromTokenStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toTokenStatus: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1073,7 +1256,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel = quotes, amount = amount, fromToken = fromTokenStatus, + fromAccount = fromAccount, toToken = toTokenStatus, + toAccount = toAccount, networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, @@ -1091,7 +1276,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel: Either, amount: SwapAmount, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, networkId: String, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1105,7 +1292,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val swapState = updateBalances( networkId = networkId, fromTokenStatus = fromToken, + fromAccount = fromAccount, toTokenStatus = toToken, + toAccount = toAccount, fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, @@ -1133,6 +1322,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val state = updatePermissionState( networkId = networkId, fromTokenStatus = fromToken, + fromAccount = fromAccount, swapAmount = amount, quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, @@ -1174,6 +1364,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ifLeft = { error -> createSwapErrorWith( fromToken = fromToken, + fromAccount = fromAccount, amount = amount, includeFeeInAmount = includeFeeInAmount, expressDataError = error, @@ -1184,6 +1375,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createSwapErrorWith( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, @@ -1194,6 +1386,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amountFiat = rates[fromToken.currency.id]?.multiply(amount.value) ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, + account = fromAccount, ) return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) } @@ -1307,7 +1500,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, selectedFee: FeeType, expressOperationType: ExpressOperationType, @@ -1345,6 +1540,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return produceDexSwapDataError( error = ExpressDataError.TooLargeSolanaTransactionError, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) } @@ -1384,7 +1580,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val swapState = updateBalances( networkId = networkId, fromTokenStatus = fromToken, + fromAccount = fromAccount, toTokenStatus = toToken, + toAccount = toAccount, fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, @@ -1413,6 +1611,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( produceDexSwapDataError( error = error, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) }, @@ -1422,6 +1621,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun produceDexSwapDataError( error: ExpressDataError, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, amount: SwapAmount, ): SwapState.SwapError { val rates = getQuotes(fromToken.currency.id) @@ -1430,6 +1630,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amountFiat = rates[fromToken.currency.id]?.multiply(amount.value) ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, + account = fromAccount, ) return SwapState.SwapError( fromTokenSwapInfo, @@ -1498,7 +1699,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, networkId: String, fromTokenStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toTokenStatus: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, @@ -1511,6 +1714,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, + account = fromAccount, cryptoCurrencyStatus = fromTokenStatus, amountFiat = rates[fromToken.id]?.multiply(fromTokenAmount.value) ?: BigDecimal.ZERO, @@ -1518,6 +1722,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, cryptoCurrencyStatus = toTokenStatus, + account = toAccount, amountFiat = rates[toToken.id]?.multiply(toTokenAmount.value) ?: BigDecimal.ZERO, ), @@ -1564,6 +1769,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updatePermissionState( networkId: String, fromTokenStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, spenderAddress: String?, @@ -1620,6 +1826,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( // it's impossible next steps without fee return createSwapErrorWith( fromToken = fromTokenStatus, + fromAccount = fromAccount, amount = swapAmount, includeFeeInAmount = IncludeFeeInAmount.Excluded, expressDataError = ExpressDataError.UnknownError, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index ef64270e26..122d227590 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet @@ -11,10 +12,13 @@ import kotlinx.coroutines.flow.Flow interface SwapTransactionRepository { + @Suppress("LongParameterList") suspend fun storeTransaction( userWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, + fromAccount: Account.CryptoPortfolio?, + toAccount: Account.CryptoPortfolio?, transaction: SavedSwapTransactionModel, ) diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index c87d68c2df..b1c706c900 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) implementation(projects.domain.express.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) + implementation(projects.domain.visa) /** Feature modules */ implementation(projects.features.swap.domain) diff --git a/features/swap/impl/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..b1f3fb02be --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -0,0 +1,91 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: TangemPayDetailsContainerComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val childStack = childStack( + key = "tangemPayDetailsInnerStack", + source = stackNavigation, + serializer = TangemPayDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayDetailsInnerRoute.Details, + childFactory = ::screenChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStack by childStack.subscribeAsState() + + BackHandler(onBack = router::pop) + Children(stack = childStack, animation = stackAnimation()) { child -> + child.instance.Content(modifier = modifier) + } + } + + private fun screenChild( + config: TangemPayDetailsInnerRoute, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + TangemPayDetailsInnerRoute.Details -> TangemPayDetailsComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + tokenReceiveComponentFactory = tokenReceiveComponentFactory, + ) + TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + ) + } + + private fun onChildBack() { + if (childStack.value.backStack.isEmpty()) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : TangemPayDetailsContainerComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayDetailsContainerComponent.Params, + ): DefaultTangemPayDetailsContainerComponent + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt new file mode 100644 index 0000000000..db43708e08 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt @@ -0,0 +1,43 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayTopUpData +import com.tangem.features.tangempay.model.TangemPayAddFundsModel +import com.tangem.features.tangempay.ui.TangemPayAddFundsContent +import java.math.BigDecimal + +internal class TangemPayAddFundsComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayAddFundsModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + TangemPayAddFundsContent(state = model.uiState) + } + + data class Params( + val listener: AddFundsListener, + val walletId: UserWalletId, + val cryptoBalance: BigDecimal, + val fiatBalance: BigDecimal, + val depositAddress: String, + val chainId: Int, + ) +} + +internal interface AddFundsListener { + fun onClickReceive(data: TangemPayTopUpData) + fun onClickSwap(data: TangemPayTopUpData) + fun onDismissAddFunds() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt new file mode 100644 index 0000000000..561ef1f9cf --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.model.TangemPayAddToWalletModel +import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreen + +internal class TangemPayAddToWalletComponent( + private val appComponentContext: AppComponentContext, + private val params: TangemPayDetailsContainerComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayAddToWalletModel = getOrCreateModel() + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("cardDetailsBlockComponent"), + params = TangemPayCardDetailsBlockComponent.Params(params = params), + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + BackHandler(onBack = router::pop) + TangemPayAddToWalletScreen( + state = state, + cardDetailsBlockComponent = cardDetailsBlockComponent, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt new file mode 100644 index 0000000000..35ae97b213 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt @@ -0,0 +1,31 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.security.DisableScreenshotsDisposableEffect +import com.tangem.features.tangempay.model.TangemPayChangePinModel +import com.tangem.features.tangempay.ui.TangemPayChangePinScreen + +internal class TangemPayChangePinComponent( + private val appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayChangePinModel = getOrCreateModel() + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + BackHandler(onBack = router::pop) + DisableScreenshotsDisposableEffect() + TangemPayChangePinScreen( + state = state, + onBackClick = router::pop, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt new file mode 100644 index 0000000000..d47fcd6737 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.ui.TangemPayChangePinCodeSuccessScreen + +internal class TangemPayChangePinSuccessComponent( + private val appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Composable + override fun Content(modifier: Modifier) { + BackHandler(onBack = ::backToDetails) + TangemPayChangePinCodeSuccessScreen(onClick = ::backToDetails) + } + + private fun backToDetails() { + router.popTo(route = TangemPayDetailsInnerRoute.Details) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt new file mode 100644 index 0000000000..6f3d6490e8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -0,0 +1,103 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation +import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.ui.TangemPayDetailsScreen +import com.tangem.features.tokenreceive.TokenReceiveComponent + +internal class TangemPayDetailsComponent( + private val appComponentContext: AppComponentContext, + private val params: TangemPayDetailsContainerComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TangemPayDetailsNavigation.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( + appComponentContext = child("txHistoryComponent"), + params = DefaultTangemPayTxHistoryComponent.Params( + customerWalletAddress = params.config.customerWalletAddress, + uiActions = model, + ), + ) + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("cardDetailsBlockComponent"), + params = TangemPayCardDetailsBlockComponent.Params(params = params), + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + NavigationBar3ButtonsScrim() + TangemPayDetailsScreen( + state = state, + txHistoryComponent = txHistoryComponent, + cardDetailsBlockComponent = cardDetailsBlockComponent, + modifier = modifier, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + navigation: TangemPayDetailsNavigation, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val context = childByContext(componentContext) + return when (navigation) { + is TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create( + context = context, + params = TokenReceiveComponent.Params( + config = navigation.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + is TangemPayDetailsNavigation.TransactionDetails -> TangemPayTxHistoryDetailsComponent( + appComponentContext = context, + params = TangemPayTxHistoryDetailsComponent.Params( + transaction = navigation.transaction, + userWalletId = params.userWalletId, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + is TangemPayDetailsNavigation.AddFunds -> TangemPayAddFundsComponent( + appComponentContext = context, + params = TangemPayAddFundsComponent.Params( + walletId = navigation.walletId, + cryptoBalance = navigation.cryptoBalance, + fiatBalance = navigation.fiatBalance, + depositAddress = navigation.depositAddress, + chainId = navigation.chainId, + listener = model, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt deleted file mode 100644 index 62e7a375f5..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.tangempay.components - -import androidx.compose.runtime.Composable -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent - -internal class TangemPayErrorBottomSheetComponent( - appComponentContext: AppComponentContext, - private val messageUM: MessageBottomSheetUMV2, - private val onDismiss: () -> Unit, -) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { - - override fun dismiss() { - onDismiss() - } - - @Composable - override fun BottomSheet() { - MessageBottomSheetV2(state = messageUM, onDismissRequest = ::dismiss) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt new file mode 100644 index 0000000000..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..42a85d03c4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM import com.tangem.features.tangempay.model.TangemPayTxHistoryModel import com.tangem.features.tangempay.ui.tangemPayTxHistoryItems +import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions import kotlinx.coroutines.flow.StateFlow internal class DefaultTangemPayTxHistoryComponent( @@ -21,5 +22,5 @@ internal class DefaultTangemPayTxHistoryComponent( tangemPayTxHistoryItems(listState, state) } - data class Params(val customerWalletAddress: String) + data class Params(val customerWalletAddress: String, val uiActions: TangemPayTxHistoryUiActions) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt index 49dccbf15e..7f04f563e2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt @@ -2,8 +2,11 @@ package com.tangem.features.tangempay.components.txHistory import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState +import com.tangem.core.ui.extensions.ImageReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayTransactionState import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM import com.tangem.features.tangempay.ui.tangemPayTxHistoryItems @@ -33,47 +36,47 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor id = "signiferumque", onClick = {}, amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "16:41", title = stringReference("StarbucksStarbucksStarbucksStarbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Payment( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "12:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - isIncome = false, + icon = ImageReference.Res(R.drawable.ic_arrow_up_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Payment( id = "signiferumque", amount = "+126.20 USD", - amountColor = { TangemTheme.colors.text.accent }, + amountColor = themedColor { TangemTheme.colors.text.accent }, time = "12:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - isIncome = true, + icon = ImageReference.Res(R.drawable.ic_arrow_down_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "12:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.GroupTitle(title = "Yesterday", itemKey = "Yesterday"), @@ -81,96 +84,96 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "21:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "10:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "19:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "18:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "17:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "16:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-4.99 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "15:41", onClick = {}, title = stringReference("Starbucks"), subtitle = stringReference("Food&Drinks"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", amount = "-126.20 USD", - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = themedColor { TangemTheme.colors.text.primary1 }, time = "14:04", onClick = {}, title = stringReference("Wallmart"), subtitle = stringReference("Supermarket"), - iconUrl = null, + icon = ImageReference.Res(R.drawable.ic_category_24), ), ), ), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt new file mode 100644 index 0000000000..345ca4a497 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.* +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel +import com.tangem.features.tangempay.ui.TangemPayTxHistoryDetailsContent + +internal class TangemPayTxHistoryDetailsComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayTxHistoryDetailsModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + TangemPayTxHistoryDetailsContent(state = model.uiState) + } + + data class Params( + val transaction: TangemPayTxHistoryItem, + val userWalletId: UserWalletId, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt index 4cd92fc806..de293a174d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt @@ -1,7 +1,9 @@ package com.tangem.features.tangempay.di -import com.tangem.features.tangempay.components.DefaultTangemPayDetailsComponent -import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.model.listener.CardDetailsEventListener +import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,7 +16,11 @@ internal interface TangemPayDetailsFeatureModule { @Binds @Singleton - fun bindTangemPayDetailsComponentFactory( - factory: DefaultTangemPayDetailsComponent.Factory, - ): TangemPayDetailsComponent.Factory + fun bindTangemPayDetailsContainerComponentFactory( + factory: DefaultTangemPayDetailsContainerComponent.Factory, + ): TangemPayDetailsContainerComponent.Factory + + @Binds + @Singleton + fun bindCardDetailsEventListener(impl: DefaultCardDetailsEventListener): CardDetailsEventListener } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index ef72f17c45..2d4297613c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -2,7 +2,12 @@ package com.tangem.features.tangempay.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.tangempay.model.TangemPayAddFundsModel +import com.tangem.features.tangempay.model.TangemPayAddToWalletModel +import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel +import com.tangem.features.tangempay.model.TangemPayChangePinModel import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel import com.tangem.features.tangempay.model.TangemPayTxHistoryModel import dagger.Binds import dagger.Module @@ -23,4 +28,29 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayTxHistoryModel::class) fun bindTangemPayTxHistoryModel(model: TangemPayTxHistoryModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayTxHistoryDetailsModel::class) + fun bindTangemPayTxHistoryDetailsModel(model: TangemPayTxHistoryDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayChangePinModel::class) + fun bindTangemPayChangePinModel(model: TangemPayChangePinModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayCardDetailsBlockModel::class) + fun bindTangemPayCardDetailsBlockModel(model: TangemPayCardDetailsBlockModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayAddToWalletModel::class) + fun bindTangemPayAddToWalletModel(model: TangemPayAddToWalletModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayAddFundsModel::class) + fun bindTangemPayAddFundsModel(model: TangemPayAddFundsModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddFundsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddFundsUM.kt new file mode 100644 index 0000000000..701f12b092 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddFundsUM.kt @@ -0,0 +1,19 @@ +package com.tangem.features.tangempay.entity + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayAddFundsUM( + val items: ImmutableList, + val dismiss: () -> Unit, + val errorMessage: MessageBottomSheetUMV2?, +) + +internal data class TangemPayAddFundsItemUM( + @DrawableRes val iconRes: Int, + val title: TextReference, + val description: TextReference, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddToWalletUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddToWalletUM.kt new file mode 100644 index 0000000000..c5aa65d1c5 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayAddToWalletUM.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class TangemPayAddToWalletUM( + val steps: ImmutableList, + val showAddToWalletButton: Boolean, + val onBackClick: () -> Unit, + val onClickOpenWallet: () -> Unit, +) + +internal data class TangemPayAddToWalletStepItemUM( + val count: Int, + val text: TextReference, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt new file mode 100644 index 0000000000..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..4582c81540 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -0,0 +1,109 @@ +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.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import com.tangem.utils.transformer.update as transformerUpdate + +@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() + + 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() + .onRight { cardDetails -> + uiState.transformerUpdate( + transformer = DetailsRevealedStateTransformer( + details = cardDetails, + onClickHide = ::hideCardDetails, + ), + ) + } + .onLeft { + uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory)) + showError() + } + }.saveIn(revealCardDetailsJobHolder) + } + + 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..826d5f4c0a --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -0,0 +1,61 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.pay.model.SetPinResult +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.features.tangempay.entity.TangemPayChangePinUM +import kotlinx.coroutines.flow.* +import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayChangePinModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val cardDetailsRepository: TangemPayCardDetailsRepository, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + private fun onPinCodeChange(pin: String) { + uiState.update(transformer = PinCodeChangeTransformer(newPin = pin)) + } + + private fun onClickSubmit() { + modelScope.launch { + uiState.update { it.copy(submitButtonLoading = true) } + val result = cardDetailsRepository.setPin(uiState.value.pinCode).getOrNull() + uiState.update { it.copy(submitButtonLoading = false) } + when (result) { + SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + SetPinResult.PIN_TOO_WEAK, + SetPinResult.DECRYPTION_ERROR, + SetPinResult.UNKNOWN_ERROR, + null, + -> Unit // TODO: [REDACTED_TASK_KEY] - add error handling once the requirements arrive + } + } + } + + private fun getInitialState(): TangemPayChangePinUM { + return TangemPayChangePinUM( + pinCode = "", + onPinCodeChange = ::onPinCodeChange, + onSubmitClick = ::onClickSubmit, + submitButtonEnabled = false, + submitButtonLoading = false, + error = null, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index da92a7606c..675f2e3db5 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,11 @@ 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 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 +57,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 +80,223 @@ 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 { + cardDetailsRepository.freezeCard(cardId = params.config.cardId) .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 { + cardDetailsRepository.unfreezeCard(cardId = params.config.cardId) + .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 = cardDetailsRepository.getCardBalance().onRight { balance = it } + 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 = cardDetailsRepository.isAddToWalletDone().getOrNull() ?: false + 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 { + cardDetailsRepository.setAddToWalletAsDone() uiState.update( - transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), + transformer = DetailsAddToWalletBannerTransformer( + onClickBanner = ::onClickAddToWalletBlock, + onClickCloseBanner = ::onClickCloseAddToWalletBlock, + isDone = true, + ), ) - cardDetailsRepository.revealCardDetails() - .onRight { - uiState.update( - transformer = DetailsRevealedStateTransformer( - details = it, - onClickHide = ::hideCardDetails, - ), - ) - } - .onLeft { - uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) - showError() - } - }.saveIn(revealCardDetailsJobHolder) + }.saveIn(addToWalletBannerJobHolder) } - private fun hideCardDetails() { - modelScope.launch { - revealCardDetailsJobHolder.cancel() - uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) - } - } - - private fun copyData(text: String) { - clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) - } - private fun showError() { - uiMessageSender.send( - SnackbarMessage(TextReference.Res(R.string.tangempay_card_details_error_text)), + override fun onClickSwap(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + router.push( + AppRoute.Swap( + currencyFrom = data.currency, + userWalletId = data.walletId, + isInitialReverseOrder = true, + screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + tangemPayInput = AppRoute.Swap.TangemPayInput( + cryptoAmount = data.cryptoBalance, + fiatAmount = data.fiatBalance, + depositAddress = data.depositAddress, + ), + ), ) } + + override fun onClickReceive(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + val config = TokenReceiveConfig( + shouldShowWarning = false, + cryptoCurrency = data.currency, + userWalletId = data.walletId, + showMemoDisclaimer = false, + receiveAddress = data.receiveAddress, + ) + bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config)) + } + + override fun onDismissAddFunds() { + bottomSheetNavigation.dismiss() + } + + override fun onTransactionClick(item: TangemPayTxHistoryItem) { + bottomSheetNavigation.activate(TangemPayDetailsNavigation.TransactionDetails(item)) + } + + 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..de78655d29 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,6 +42,7 @@ internal class TangemPayTxHistoryModel @Inject constructor( handleBalanceHiding() launchPagination() subscribeToUiItemChanges() + subscribeToUpdateListener() } private fun launchPagination() { @@ -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..867751f14a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt @@ -1,14 +1,14 @@ package com.tangem.features.tangempay.model.transformers -import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory -import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.utils.transformer.Transformer internal class DetailsHiddenStateTransformer( - private val stateFactory: TangemPayDetailsStateFactory, -) : Transformer { + private val stateFactory: TangemPayCardDetailsBlockStateFactory, +) : Transformer { - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - return prevState.copy(cardDetailsUM = stateFactory.getInitialState().cardDetailsUM) + override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM { + return stateFactory.getInitialState() } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt index b4c1968160..182248384b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt @@ -2,21 +2,19 @@ package com.tangem.features.tangempay.model.transformers import com.tangem.core.ui.extensions.TextReference import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.utils.transformer.Transformer internal class DetailsRevealProgressStateTransformer( private val onClickHide: (() -> Unit), -) : Transformer { +) : Transformer { - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM { return prevState.copy( - cardDetailsUM = prevState.cardDetailsUM.copy( - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - onClick = onClickHide, - isLoading = true, - isHidden = true, - ), + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + onClick = onClickHide, + isLoading = true, + isHidden = true, ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt index 477f82b511..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..a564990a6e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -0,0 +1,306 @@ +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.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.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(204f / 130f) // 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)), + ) { + 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_frozen_tangem_pay_visa + 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 { + Box(modifier = Modifier.fillMaxSize()) { + 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/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..7b91afb8fd 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 */ @@ -39,4 +41,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..a102748888 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,13 +6,20 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.features.tangempay.TangemPayConstants +import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.features.tangempay.components.TangemPayOnboardingComponent +import com.tangem.features.tangempay.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 com.tangem.utils.transformer.update as transformerUpdate import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @Stable @@ -22,12 +29,13 @@ internal class TangemPayOnboardingModel @Inject constructor( private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val repository: OnboardingRepository, + private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase, + private val urlOpener: UrlOpener, ) : Model() { private val params = paramsContainer.require() - - val screenState: StateFlow - field = MutableStateFlow(TangemPayOnboardingScreenState()) + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) init { modelScope.launch { @@ -37,19 +45,24 @@ 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() { @@ -58,8 +71,7 @@ internal class TangemPayOnboardingModel @Inject constructor( 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 +80,47 @@ 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 { + val result = produceInitialDataUseCase() + if (result.isLeft()) { + Timber.e("Error producing initial data: ${result.leftOrNull()?.message}") + uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) + return@launch + } + + repository.getCustomerInfo() + .fold( + ifLeft = { + Timber.e("Error getCustomerInfo: ${it.errorCode}") + uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) + }, + ifRight = { customerInfo -> + if (customerInfo.isKycApproved) { + back() + } else { + openKyc() + } + }, + ) + } + } + + private fun openKyc() { + router.replaceAll(AppRoute.Wallet, AppRoute.Kyc) + } + + private fun back() { + router.pop() + } + + private fun getInitialState(): TangemPayOnboardingScreenState { + return TangemPayOnboardingScreenState.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..b30c97ef7c 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,225 @@ 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.layout.* +import androidx.compose.foundation.rememberScrollState +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.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 = 200.dp, height = 130.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_22, + 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_22, + 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_22, + 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