diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2de49eab54..097bcd4c84 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -273,6 +273,8 @@ dependencies { implementation(projects.features.welcome.impl) implementation(projects.features.createWalletSelection.api) implementation(projects.features.createWalletSelection.impl) + implementation(projects.features.createWalletStart.api) + implementation(projects.features.createWalletStart.impl) implementation(projects.features.home.api) implementation(projects.features.home.impl) implementation(projects.features.account.api) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 798d610177..5322b95a0c 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -1,8 +1,8 @@ package com.tangem.common import android.Manifest +import androidx.compose.ui.test.isRoot import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.printToLog import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents @@ -121,6 +121,8 @@ abstract class BaseTestCase : TestCase( /** * Prints the Compose semantics tree to logcat for debugging UI tests. * + * @param rootIndex Use rootIndex > 0, if you need to print semantics tree for bottom sheet. + * Default: 0. * @param useUnmergedTree When true, shows unmerged tree with all individual nodes. * Use for accessing inner elements of compound components. * Default: false (merged tree - accessibility view). @@ -129,11 +131,13 @@ abstract class BaseTestCase : TestCase( * Default: Int.MAX_VALUE (unlimited depth). */ fun printSemanticTree( + rootIndex: Int = 0, useUnmergedTree: Boolean = false, tag: String = "SEMANTIC_TREE", - maxDepth: Int = Int.MAX_VALUE) - { - composeTestRule.onRoot(useUnmergedTree = useUnmergedTree).printToLog(tag, maxDepth) + maxDepth: Int = Int.MAX_VALUE + ) { + composeTestRule.onAllNodes(isRoot(), useUnmergedTree = useUnmergedTree)[rootIndex] + .printToLog(tag, maxDepth) } fun waitForIdle() = composeTestRule.waitForIdle() 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 82b612164d..62e83ab9c4 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -4,6 +4,7 @@ object TestConstants { const val TOTAL_BALANCE = "$3,299.18" const val RECIPIENT_ADDRESS = "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0" + const val BITCOIN_ADDRESS = "bc1qtg9aa6jcpqtvun0pe0uct7sxm8nq2nsxfmfxm3" const val CARDANO_ADDRESS = "addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p" const val WAIT_UNTIL_TIMEOUT = 20_000L diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt new file mode 100644 index 0000000000..c2e3981abc --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt @@ -0,0 +1,38 @@ +package com.tangem.common.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider + +fun getClipboardText(context: Context): String? { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + return if (clipboard.hasPrimaryClip()) { + clipboard.primaryClip?.getItemAt(0)?.text?.toString() + } else { + null + } +} + +fun setClipboardText(context: Context, text: String?) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("label", text) + clipboard.setPrimaryClip(clip) +} + +fun clearClipboard( + context: Context = ApplicationProvider.getApplicationContext() +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.clearPrimaryClip() +} + +fun assertClipboardTextEquals( + expected: String, + context: Context = ApplicationProvider.getApplicationContext() +) { + val actual = getClipboardText(context) + assert(actual == expected) { + "Clipboard text mismatch.\nExpected: '$expected'\nActual: '$actual'" + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 3212a90432..d07178f3cb 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -85,4 +85,13 @@ fun BaseTestCase.openDeviceSettingsScreen() { step("Click on 'Device settings' button") { onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() } } +} + +fun BaseTestCase.openWalletConnectScreen() { + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/StakingScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/StakingScenarios.kt new file mode 100644 index 0000000000..f803796469 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/StakingScenarios.kt @@ -0,0 +1,124 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onSendScreen +import com.tangem.screens.onStakingConfirmScreen +import com.tangem.screens.onStakingDetailsScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkStakingDetailsScreen(withStaking: Boolean) { + step("Assert 'Title' is displayed") { + onStakingDetailsScreen { stakingTitle.assertIsDisplayed() } + } + step("Assert 'Annual percentage rate' is displayed") { + onStakingDetailsScreen { annualPercentageRate.assertIsDisplayed() } + } + step("Assert 'Available' block is displayed") { + onStakingDetailsScreen { availableBlock.assertIsDisplayed() } + } + step("Assert 'Unbonding Period' block is displayed") { + onStakingDetailsScreen { unbondingPeriodBlock.assertIsDisplayed() } + } + step("Assert 'Reward claiming' block is displayed") { + onStakingDetailsScreen { rewardClaimingBlock.assertIsDisplayed() } + } + step("Assert 'Reward schedule' block is displayed") { + onStakingDetailsScreen { rewardScheduleBlock.assertIsDisplayed() } + } + step("Assert 'ToS' text is displayed") { + onStakingDetailsScreen { toSText.assertIsDisplayed() } + } + if (withStaking) { + step("Assert 'Rewards block' is displayed") { + onStakingDetailsScreen { rewardsBlock.assertIsDisplayed() } + } + step("Assert 'Rewards block' title is displayed") { + onStakingDetailsScreen { rewardsBlockTitle.assertIsDisplayed() } + } + step("Assert 'Rewards block' text is displayed") { + onStakingDetailsScreen { rewardsBlockText.assertIsDisplayed() } + } + step("Assert 'Active staking block' is displayed") { + onStakingDetailsScreen { activeStakingBlock.assertIsDisplayed() } + } + step("Assert 'Your stakes' title is displayed") { + onStakingDetailsScreen { yourStakesTitle.assertIsDisplayed() } + } + step("Assert 'Stake more' button is displayed") { + onStakingDetailsScreen { stakeMoreButton.assertIsDisplayed() } + } + } else { + step("Assert banner image is displayed") { + onStakingDetailsScreen { bannerImage.assertIsDisplayed() } + } + step("Assert banner text is displayed") { + onStakingDetailsScreen { bannerText.assertIsDisplayed() } + } + step("Assert 'Stake' button is displayed") { + onStakingDetailsScreen { stakeButton.assertIsDisplayed() } + } + } + +} +fun BaseTestCase.checkStakingScreen(stakingAmount: String) { + step("Assert 'Staking' screen is displayed") { + onSendScreen { screenContainer.assertIsDisplayed() } + } + step("Assert top app bar 'Close' button is displayed") { + onSendScreen { closeButton.assertIsDisplayed() } + } + step("Assert 'Send' screen title is displayed") { + onSendScreen { title.assertIsDisplayed() } + } + step("Assert amount container title is displayed") { + onSendScreen { amountContainerTitle.assertIsDisplayed() } + } + step("Assert input text field is displayed") { + onSendScreen { amountInputTextField.assertIsDisplayed() } + } + step("Assert token name is displayed") { + onSendScreen { tokenName.assertIsDisplayed() } + } + step("Assert primary amount is displayed") { + onSendScreen { primaryAmount.assertIsDisplayed() } + } + step("Assert secondary amount is displayed") { + onSendScreen { secondaryAmount.assertIsDisplayed() } + } + step("Type '$stakingAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(stakingAmount) + } + } + step("Assert input text field has value: '$stakingAmount'") { + onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } + } + step("Assert 'Max' button is displayed") { + onSendScreen { maxButton.assertIsDisplayed() } + } + step("Assert 'Next' button is displayed") { + onSendScreen { nextButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkStakingConfirmScreen() { + step("Assert 'Staking confirm' screen title is displayed") { + onStakingConfirmScreen { title.assertIsDisplayed() } + } + step("Assert primary amount is displayed") { + onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } + } + step("Assert secondary amount is displayed") { + onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } + } + step("Assert 'Validator' block is displayed") { + onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } + } + step("Assert 'Network Fee' block is displayed") { + onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } + } + step("Assert 'Stake' button is displayed") { + onStakingConfirmScreen { stakeButton.assertIsDisplayed() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt index 107806004a..facffbfd2d 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt @@ -7,6 +7,7 @@ import com.tangem.screens.onWalletConnectScreen import io.qameta.allure.kotlin.Allure.step fun BaseTestCase.checkWalletConnectBottomSheet() { + waitForIdle() step("Assert 'Wallet Connect' bottom sheet title is displayed") { onWalletConnectBottomSheet { title.assertIsDisplayed() } } @@ -60,34 +61,64 @@ fun BaseTestCase.checkWalletConnectBottomSheet() { } } -fun BaseTestCase.checkWalletConnectScreen() { +fun BaseTestCase.checkWalletConnectScreen(withConnections: Boolean) { + waitForIdle() step("Assert 'Wallet Connect' title is displayed") { onWalletConnectScreen { title.assertIsDisplayed() } } - step("Assert 'More' button is displayed") { - onWalletConnectScreen { moreButton.assertIsDisplayed() } - } - step("Assert wallet name is displayed") { - onWalletConnectScreen { walletName.assertIsDisplayed() } - } - step("Assert app icon is displayed") { - onWalletConnectScreen { appIcon.assertIsDisplayed() } - } - step("Assert app name is displayed") { - onWalletConnectScreen { appName.assertIsDisplayed() } - } - step("Assert approve icon is displayed") { - onWalletConnectScreen { approveIcon.assertIsDisplayed() } - } - step("Assert app URL is displayed") { - onWalletConnectScreen { appUrl.assertIsDisplayed() } - } step("Assert 'New Connection' button is displayed") { onWalletConnectScreen { newConnectionButton.assertIsDisplayed() } } + if (withConnections) { + step("Assert 'More' button is displayed") { + onWalletConnectScreen { moreButton.assertIsDisplayed() } + } + step("Assert wallet name is displayed") { + onWalletConnectScreen { walletName.assertIsDisplayed() } + } + step("Assert app icon is displayed") { + onWalletConnectScreen { appIcon.assertIsDisplayed() } + } + step("Assert app name is displayed") { + onWalletConnectScreen { appName.assertIsDisplayed() } + } + step("Assert approve icon is displayed") { + onWalletConnectScreen { approveIcon.assertIsDisplayed() } + } + step("Assert app URL is displayed") { + onWalletConnectScreen { appUrl.assertIsDisplayed() } + } + } else { + step("Assert wallet name is not displayed") { + onWalletConnectScreen { walletName.assertIsNotDisplayed() } + } + step("Assert app icon is not displayed") { + onWalletConnectScreen { appIcon.assertIsNotDisplayed() } + } + step("Assert app name is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + step("Assert approve icon is not displayed") { + onWalletConnectScreen { approveIcon.assertIsNotDisplayed() } + } + step("Assert app URL is not displayed") { + onWalletConnectScreen { appUrl.assertIsNotDisplayed() } + } + step("Assert 'Wallet Connect' image is displayed") { + onWalletConnectScreen { walletConnectImage.assertIsDisplayed() } + } + step("Assert 'No session' title is displayed") { + onWalletConnectScreen { noSessionTitle.assertIsDisplayed() } + } + step("Assert 'No session' text is displayed") { + onWalletConnectScreen { noSessionText.assertIsDisplayed() } + } + } + } fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { + waitForIdle() step("Assert connection details title is displayed") { onWalletConnectDetailsBottomSheet { title.assertIsDisplayed() } } @@ -128,7 +159,7 @@ fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { onWalletConnectDetailsBottomSheet { connectedNetworkIcon.assertIsDisplayed() } } step("Assert connected dApp name: '$dAppName'") { - onWalletConnectDetailsBottomSheet { connectedNetworkName.assertTextContains(dAppName) } + onWalletConnectDetailsBottomSheet { appName.assertTextContains(dAppName) } } step("Assert connected network symbol is displayed") { onWalletConnectDetailsBottomSheet { connectedNetworkSymbol.assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ChromeBrowserPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ChromeBrowserPageObject.kt new file mode 100644 index 0000000000..78c2fa41dd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ChromeBrowserPageObject.kt @@ -0,0 +1,55 @@ +package com.tangem.screens + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import com.kaspersky.kaspresso.screens.KScreen + +object ChromeBrowserPageObject : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + private const val TIMEOUT = 10000L + + fun assertChromeIsOpened() { + val chromePackage = "com.android.chrome" + device.wait(Until.hasObject(By.pkg(chromePackage).depth(0)), TIMEOUT) + assert(device.hasObject(By.pkg(chromePackage))) { + "Chrome browser is not opened" + } + } + + fun assertUrlContains(expectedUrl: String) { + val urlBar = device.findObject( + By.res("com.android.chrome:id/url_bar") + ) + urlBar?.let { + assert(it.text.contains(expectedUrl)) { + "URL doesn't contain expected: $expectedUrl, actual: ${it.text}" + } + } + } + + private fun findElementByText(text: String): UiObject2? { + device.wait(Until.hasObject(By.text(text)), TIMEOUT) + return device.findObject(By.text(text)) + } + + fun assertElementWithTextExists(text: String) { + val element = findElementByText(text) + assert(element != null) { "Element with text '$text' not found" } + } + + fun isElementWithTextExists(text: String): Boolean { + return device.wait(Until.hasObject(By.text(text)), TIMEOUT) + } + + fun clickOnElementWithText(text: String) { + findElementByText(text)?.click() + ?: throw AssertionError("Cannot click - element with text '$text' not found") + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index da077e296f..d1ce4ef047 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -17,6 +17,7 @@ import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode 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 MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -200,6 +201,10 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + val snackbarCopiedAddressMessage: KNode = child { + hasText(getResourceString(CoreUiR.string.wallet_notification_address_copied)) + } + /** * Find token list item with title and address */ diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt similarity index 50% rename from app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt index d40a96c38a..acea682a27 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt @@ -3,20 +3,19 @@ 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 -class BaseBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class ReceiveAssetsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { - val hideButton: KNode = child { - hasText(getResourceString(R.string.token_details_hide_token)) - hasTestTag(BaseBottomSheetTestTags.ACTION_TITLE) + val showQrCodeButton: KNode = child { + hasText(getResourceString(R.string.token_receive_show_qr_code_title)) + useUnmergedTree = true } } -internal fun BaseTestCase.onBottomSheet(function: BaseBottomSheetPageObject.() -> Unit) = +internal fun BaseTestCase.onReceiveAssetsBottomSheet(function: ReceiveAssetsBottomSheetPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt index f988ad6de5..d12952956b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -10,7 +10,6 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import com.tangem.features.send.v2.impl.R as SendR -import androidx.compose.ui.test.hasTestTag as withTestTag class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -19,6 +18,11 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(SendScreenTestTags.SCREEN_CONTAINER) } + val closeButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + val title: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) useUnmergedTree = true @@ -29,13 +33,18 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } - val amountContainerText: KNode = child { - hasTestTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT) + val amountInputTextField: KNode = child { + hasTestTag(SendScreenTestTags.INPUT_TEXT_FIELD) useUnmergedTree = true } - val amountInputTextField: KNode = child { - hasTestTag(SendScreenTestTags.INPUT_TEXT_FIELD) + val tokenName: KNode = child { + hasTestTag(SendScreenTestTags.TOKEN_NAME) + useUnmergedTree = true + } + + val primaryAmount: KNode = child { + hasTestTag(SendScreenTestTags.PRIMARY_AMOUNT) useUnmergedTree = true } @@ -44,28 +53,11 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } - val currencyButton: KNode = child { - hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(SendScreenTestTags.CURRENCY_ICON)) - useUnmergedTree = true - } - - val fiatButton: KNode = child { - hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(SendScreenTestTags.FIAT_ICON)) - useUnmergedTree = true - } - val maxButton: KNode = child { hasTestTag(SendScreenTestTags.MAX_BUTTON) useUnmergedTree = true } - val previousButton: KNode = child { - hasTestTag(SendScreenTestTags.PREVIOUS_BUTTON) - useUnmergedTree = true - } - val nextButton: KNode = child { hasTestTag(BaseButtonTestTags.TEXT) hasText(getResourceString(SendR.string.common_next)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index d069f7abe1..d2f4fa7b1b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -71,6 +71,10 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_swap)) } + fun tokenSymbol(symbol: String): KNode = child { + hasTestTag(SwapTokenScreenTestTags.TOKEN_SYMBOL) + } + } internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenActionsBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenActionsBottomSheetPageObject.kt new file mode 100644 index 0000000000..af3647e158 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenActionsBottomSheetPageObject.kt @@ -0,0 +1,75 @@ +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 + +class TokenActionsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val analyticsButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_analytics))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val copyAddressButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_copy_address))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val receiveButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_receive))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val sendButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_send))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val swapButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_swap))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val buyButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_buy))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val sellButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_sell))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val hideTokenButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.token_details_hide_token))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTokenActionsBottomSheet(function: TokenActionsBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt new file mode 100644 index 0000000000..e6bb3c228b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt @@ -0,0 +1,56 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags +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 + +class TokenReceiveQrCodeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val closeButton: KNode = child { + hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val title: KNode = child { + hasTestTag(TokenReceiveQrCodeBottomSheetTestTags.TITLE) + useUnmergedTree = true + } + + val qrCode: KNode = child { + hasTestTag(TokenReceiveQrCodeBottomSheetTestTags.QR_CODE) + useUnmergedTree = true + } + + val addressTitle: KNode = child { + hasText(getResourceString(R.string.wc_common_address)) + useUnmergedTree = true + } + + val address: KNode = child { + hasTestTag(TokenReceiveQrCodeBottomSheetTestTags.ADDRESS) + useUnmergedTree = true + } + + val copyButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_copy)) + useUnmergedTree = true + } + + val shareButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_share)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTokenReceiveQrCodeBottomSheet(function: TokenReceiveQrCodeBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt new file mode 100644 index 0000000000..2a0148863b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt @@ -0,0 +1,28 @@ +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.TokenReceiveWarningBottomSheetTestTags +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 TokenReceiveWarningBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val bottomSheet: KNode = child { + hasTestTag(TokenReceiveWarningBottomSheetTestTags.BOTTOM_SHEET) + } + + val gotItButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_got_it)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTokenReceiveWarningBottomSheet(function: TokenReceiveWarningBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt index c111e92406..8490c75e76 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt @@ -9,13 +9,11 @@ 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.features.walletconnect.impl.R as WalletConnectImplR class WalletConnectBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { val title: KNode = child { - hasText(getResourceString(WalletConnectImplR.string.wc_wallet_connect)) hasTestTag(WalletConnectBottomSheetTestTags.TITLE) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt index b9da100325..cb84e644f1 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt @@ -53,6 +53,21 @@ class WalletConnectPageObject(semanticsProvider: SemanticsNodeInteractionsProvid hasText(getResourceString(R.string.wc_new_connection)) useUnmergedTree = true } + + val walletConnectImage: KNode = child { + hasTestTag(WalletConnectScreenTestTags.WALLET_CONNECT_IMAGE) + useUnmergedTree = true + } + + val noSessionTitle: KNode = child { + hasText(getResourceString(R.string.wc_no_sessions_title)) + useUnmergedTree = true + } + + val noSessionText: KNode = child { + hasText(getResourceString(R.string.wc_no_sessions_desc)) + useUnmergedTree = true + } } internal fun BaseTestCase.onWalletConnectScreen(function: WalletConnectPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt new file mode 100644 index 0000000000..d48c6e7e67 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt @@ -0,0 +1,23 @@ +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 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) { + + val pasteFromClipboardButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.wallet_connect_paste_from_clipboard)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onWalletConnectScanQrScreen(function: WalletConnectScanQrPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt index f1639df077..856a3963b3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt @@ -1,6 +1,10 @@ package com.tangem.tests import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.domain.models.scan.ProductType import com.tangem.scenarios.checkMultiCurrencyMainScreen import com.tangem.scenarios.checkSingleCurrencyMainScreen @@ -15,6 +19,10 @@ import org.junit.Test @HiltAndroidTest class ScanCardTest : BaseTestCase() { + @ApiEnv( + ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD), + ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) + ) @AllureId("868") @DisplayName("Scan: Scanning single-currency cards") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index 8bbb02ebab..29404214c2 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -7,9 +7,8 @@ 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.* -import com.tangem.scenarios.openMainScreen -import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -117,116 +116,20 @@ class StakingTest : BaseTestCase() { step("Click on 'Staking block'") { onTokenDetailsScreen { stakingBlock.clickWithAssertion() } } - step("Assert 'Title' is displayed") { - onStakingDetailsScreen { stakingTitle.assertIsDisplayed() } - } - step("Assert 'Annual percentage rate' is displayed") { - onStakingDetailsScreen { annualPercentageRate.assertIsDisplayed() } - } - step("Assert 'Available' block is displayed") { - onStakingDetailsScreen { availableBlock.assertIsDisplayed() } - } - step("Assert 'Unbonding Period' block is displayed") { - onStakingDetailsScreen { unbondingPeriodBlock.assertIsDisplayed() } - } - step("Assert 'Reward claiming' block is displayed") { - onStakingDetailsScreen { rewardClaimingBlock.assertIsDisplayed() } - } - step("Assert 'Reward schedule' block is displayed") { - onStakingDetailsScreen { rewardScheduleBlock.assertIsDisplayed() } - } - step("Assert 'Rewards block' is displayed") { - onStakingDetailsScreen { rewardsBlock.assertIsDisplayed() } - } - step("Assert 'Rewards block' title is displayed") { - onStakingDetailsScreen { rewardsBlockTitle.assertIsDisplayed() } - } - step("Assert 'Rewards block' text is displayed") { - onStakingDetailsScreen { rewardsBlockText.assertIsDisplayed() } - } - step("Assert 'Active staking block' is displayed") { - onStakingDetailsScreen { activeStakingBlock.assertIsDisplayed() } - } - step("Assert 'Your stakes' title is displayed") { - onStakingDetailsScreen { yourStakesTitle.assertIsDisplayed() } - } - step("Assert 'ToS' text is displayed") { - onStakingDetailsScreen { toSText.assertIsDisplayed() } - } - step("Assert 'Stake more' button is displayed") { - onStakingDetailsScreen { stakeMoreButton.assertIsDisplayed() } + step("Check 'Staking details' screen") { + checkStakingDetailsScreen(withStaking = true) } step("Click 'Stake more' button") { onStakingDetailsScreen { stakeMoreButton.performClick() } } - step("Assert 'Send' screen is displayed") { - onSendScreen { screenContainer.assertIsDisplayed() } - } - step("Assert 'Send' screen title is displayed") { - onSendScreen { title.assertIsDisplayed() } - } - step("Assert amount container title is displayed") { - onSendScreen { amountContainerTitle.assertIsDisplayed() } - } - step("Assert amount container text is displayed") { - onSendScreen { amountContainerText.assertIsDisplayed() } - } - step("Assert input text field is displayed") { - onSendScreen { amountInputTextField.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onSendScreen { secondaryAmount.assertIsDisplayed() } - } - step("Type '$stakingAmount' in input text field") { - onSendScreen { - amountInputTextField.performClick() - amountInputTextField.performTextReplacement(stakingAmount) - } - } - step("Assert input text field has value: '$stakingAmount'") { - onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert 'Max' button is displayed") { - onSendScreen { maxButton.assertIsDisplayed() } - } - step("Assert previous button is displayed") { - onSendScreen { previousButton.assertIsDisplayed() } - } - step("Assert 'Next' button is displayed") { - onSendScreen { nextButton.assertIsDisplayed() } + step("Check 'Staking' screen") { + checkStakingScreen(stakingAmount) } step("Click on 'Next' button") { onSendScreen { nextButton.performClick() } } - step("Assert 'Send details' screen title is displayed") { - onStakingConfirmScreen { title.assertIsDisplayed() } - } - step("Assert primary amount is displayed") { - onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } - } - step("Assert 'Validator' block is displayed") { - onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } - } - step("Assert 'Network Fee' block is displayed") { - onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } - } - step("Assert 'Stake' button is displayed") { - onStakingConfirmScreen { stakeButton.assertIsDisplayed() } + step("Check 'Staking confirm' screen") { + checkStakingConfirmScreen() } } } @@ -284,107 +187,20 @@ class StakingTest : BaseTestCase() { step("Click on 'Stake' button") { onTokenDetailsScreen { stakeButton.clickWithAssertion() } } - step("Assert 'Title' is displayed") { - onStakingDetailsScreen { stakingTitle.assertIsDisplayed() } - } - step("Assert banner image is displayed") { - onStakingDetailsScreen { bannerImage.assertIsDisplayed() } - } - step("Assert banner text is displayed") { - onStakingDetailsScreen { bannerText.assertIsDisplayed() } - } - step("Assert 'Annual percentage rate' is displayed") { - onStakingDetailsScreen { annualPercentageRate.assertIsDisplayed() } - } - step("Assert 'Available' block is displayed") { - onStakingDetailsScreen { availableBlock.assertIsDisplayed() } - } - step("Assert 'Unbonding Period' block is displayed") { - onStakingDetailsScreen { unbondingPeriodBlock.assertIsDisplayed() } - } - step("Assert 'Reward claiming' block is displayed") { - onStakingDetailsScreen { rewardClaimingBlock.assertIsDisplayed() } - } - step("Assert 'Reward schedule' block is displayed") { - onStakingDetailsScreen { rewardScheduleBlock.assertIsDisplayed() } - } - step("Assert 'ToS' text is displayed") { - onStakingDetailsScreen { toSText.assertIsDisplayed() } - } - step("Assert 'Stake' button is displayed") { - onStakingDetailsScreen { stakeButton.assertIsDisplayed() } + step("Check 'Staking details' screen") { + checkStakingDetailsScreen(withStaking = false) } step("Click 'Stake' button") { onStakingDetailsScreen { stakeButton.performClick() } } - step("Assert 'Send' screen is displayed") { - onSendScreen { screenContainer.assertIsDisplayed() } - } - step("Assert 'Send' screen title is displayed") { - onSendScreen { title.assertIsDisplayed() } - } - step("Assert amount container title is displayed") { - onSendScreen { amountContainerTitle.assertIsDisplayed() } - } - step("Assert amount container text is displayed") { - onSendScreen { amountContainerText.assertIsDisplayed() } - } - step("Assert input text field is displayed") { - onSendScreen { amountInputTextField.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onSendScreen { secondaryAmount.assertIsDisplayed() } - } - step("Type '$stakingAmount' in input text field") { - onSendScreen { - amountInputTextField.performClick() - amountInputTextField.performTextReplacement(stakingAmount) - } - } - step("Assert input text field has value: '$stakingAmount'") { - onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert 'Max' button is displayed") { - onSendScreen { maxButton.assertIsDisplayed() } - } - step("Assert previous button is displayed") { - onSendScreen { previousButton.assertIsDisplayed() } - } - step("Assert 'Next' button is displayed") { - onSendScreen { nextButton.assertIsDisplayed() } + step("Check 'Staking' screen") { + checkStakingScreen(stakingAmount) } step("Click on 'Next' button") { onSendScreen { nextButton.performClick() } } - step("Assert 'Send details' screen title is displayed") { - onStakingConfirmScreen { title.assertIsDisplayed() } - } - step("Assert primary amount is displayed") { - onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } - } - step("Assert 'Validator' block is displayed") { - onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } - } - step("Assert 'Network Fee' block is displayed") { - onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } - } - step("Assert 'Stake' button is displayed") { - onStakingConfirmScreen { stakeButton.assertIsDisplayed() } + step("Check 'Staking confirm' screen") { + checkStakingConfirmScreen() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt index db5e364f20..13fa9b6a47 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -2,12 +2,13 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE -import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeVertical import com.tangem.common.utils.getWcUri +import com.tangem.common.utils.setClipboardText import com.tangem.scenarios.* import com.tangem.screens.* +import com.tangem.wallet.BuildConfig import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -21,7 +22,7 @@ class WalletConnectTest : BaseTestCase() { @DisplayName("WC (React App): open session from deeplink on main screen") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test - fun openWalletConnectSessionOnMainScreen() { + fun openWalletConnectSessionOnMainScreenTest() { val balance = TOTAL_BALANCE val dAppName = "React App" val deepLinkUri = getWcUri() @@ -37,19 +38,28 @@ class WalletConnectTest : BaseTestCase() { openAppByDeepLink(deepLinkUri) } step("Check 'Wallet Connect' bottom sheet") { - checkWalletConnectBottomSheet() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } + } + step("Assert 'Connect' button is enabled") { + onWalletConnectBottomSheet { connectButton.assertIsEnabled() } } step("Click on 'Connect' button") { + waitForIdle() onWalletConnectBottomSheet { connectButton.performClick() } } - step("Click 'More' button on TopBar") { - onTopBar { moreButton.clickWithAssertion() } + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } } - step("Click on 'Wallet Connect' button") { - onDetailsScreen { walletConnectButton.clickWithAssertion() } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() } - step("Check 'Wallet Connect' screen") { - checkWalletConnectScreen() + step("Check 'Wallet Connect' screen with connections") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectScreen(withConnections = true) + } } step("Click on app icon") { onWalletConnectScreen { appIcon.performClick() } @@ -57,11 +67,11 @@ class WalletConnectTest : BaseTestCase() { step("Check 'Wallet Connect' details bottom sheet") { checkWalletConnectDetailsBottomSheet(dAppName) } - step("Click on 'Disconnect button' is displayed") { + step("Click on 'Disconnect' button") { onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } } - step("Assert connection is not displayed") { - onWalletConnectScreen { appName.assertIsNotDisplayed() } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) } } } @@ -70,7 +80,7 @@ class WalletConnectTest : BaseTestCase() { @DisplayName("WC (React App): open session from deeplink not on main screen") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test - fun openWalletConnectSessionNotOnMainScreen() { + fun openWalletConnectSessionNotOnMainScreenTest() { val balance = TOTAL_BALANCE val dAppName = "React App" val deepLinkUri = getWcUri() @@ -82,41 +92,44 @@ class WalletConnectTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses(balance) } - step("Click on 'Buy' button") { - onMainScreen { buyButton.clickWithAssertion() } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + checkWalletConnectScreen(false) } step("Create WC session buy deeplink") { openAppByDeepLink(deepLinkUri) } step("Check 'Wallet Connect' bottom sheet") { - checkWalletConnectBottomSheet() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } } step("Click on 'Connect' button") { + waitForIdle() onWalletConnectBottomSheet { connectButton.performClick() } } - step("Click 'More' button on TopBar") { - onTopBar { moreButton.clickWithAssertion() } + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } } - step("Click on 'Wallet Connect' button") { - onDetailsScreen { walletConnectButton.clickWithAssertion() } - } - step("Assert 'Wallet Connect' bottom sheet is displayed") { - onWalletConnectBottomSheet { connectButton.clickWithAssertion() } - } - step("Check 'Wallet Connect' screen") { - checkWalletConnectScreen() + step("Check 'Wallet Connect' screen with connections") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectScreen(withConnections = true) + } } step("Click on app icon") { onWalletConnectScreen { appIcon.performClick() } } step("Check 'Wallet Connect' details bottom sheet") { - checkWalletConnectDetailsBottomSheet(dAppName) + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectDetailsBottomSheet(dAppName) + } } - step("Click on 'Disconnect button' is displayed") { + step("Click on 'Disconnect' button") { onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } } - step("Assert connection is not displayed") { - onWalletConnectScreen { appName.assertIsNotDisplayed() } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) } } } @@ -125,9 +138,10 @@ class WalletConnectTest : BaseTestCase() { @DisplayName("WC (React App): open session from deeplink ") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test - fun openWalletConnectSession() { + fun openWalletConnectSessionTest() { val balance = TOTAL_BALANCE val dAppName = "React App" + val packageName = BuildConfig.APPLICATION_ID val deepLinkUri = getWcUri() setupHooks().run { @@ -137,32 +151,28 @@ class WalletConnectTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses(balance) } - step("Open recent apps") { - device.uiDevice.pressRecentApps() - } - step("Stop app by swipe") { - swipeVertical(SwipeDirection.UP, startHeightRatio = 0.8f) + step("Kill app") { + device.apps.kill(packageName) } step("Create WC session buy deeplink") { openAppByDeepLink(deepLinkUri) } - step("Open 'Main Screen'") { - openMainScreen() - } step("Check 'Wallet Connect' bottom sheet") { - checkWalletConnectBottomSheet() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } } step("Click on 'Connect' button") { onWalletConnectBottomSheet { connectButton.performClick() } } - step("Click 'More' button on TopBar") { - onTopBar { moreButton.clickWithAssertion() } + step("Assert 'Connect' button is not displayed") { + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } } - step("Click on 'Wallet Connect' button") { - onDetailsScreen { walletConnectButton.clickWithAssertion() } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() } - step("Check 'Wallet Connect' screen") { - checkWalletConnectScreen() + step("Check 'Wallet Connect' screen with connections") { + checkWalletConnectScreen(withConnections = true) } step("Click on app icon") { onWalletConnectScreen { appIcon.performClick() } @@ -170,11 +180,72 @@ class WalletConnectTest : BaseTestCase() { step("Check 'Wallet Connect' details bottom sheet") { checkWalletConnectDetailsBottomSheet(dAppName) } - step("Click on 'Disconnect button' is displayed") { + step("Click on 'Disconnect' button") { onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } } - step("Assert connection is not displayed") { - onWalletConnectScreen { appName.assertIsNotDisplayed() } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) + } + } + } + + @AllureId("887") + @DisplayName("WC: open session by 'Paste from clipboard' button") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSessionByClipboardLinkTest() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val context = device.context + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Set URI to clipboard") { + setClipboardText(context, deepLinkUri) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + step("Click 'New connection' button") { + onWalletConnectScreen { newConnectionButton.performClick() } + } + step("CLick 'Paste from clipboard' button") { + onWalletConnectScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' bottom sheet") { + waitForIdle() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } + } + step("Click on 'Connect' button") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + } + step("Check 'Wallet Connect' screen with connections") { + checkWalletConnectScreen(withConnections = true) + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect' button") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt new file mode 100644 index 0000000000..76debeb60a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -0,0 +1,405 @@ +package com.tangem.tests.actionButtons + +import androidx.compose.ui.test.longClick +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.BITCOIN_ADDRESS +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +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.screens.* +import com.tangem.screens.onTokenActionsBottomSheet +import com.tangem.screens.onBuyTokenDetailsScreen +import com.tangem.screens.onDialog +import com.tangem.screens.onMainScreen +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 MainScreenActionButtonsTest : BaseTestCase() { + + @AllureId("79") + @DisplayName("Action buttons (long tap): validate UI") + @Test + fun actionButtonsValidateLongTapUiTest() { + val tokenTitle = "Ethereum" + val balance = TOTAL_BALANCE + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Analytics' button is displayed") { + onTokenActionsBottomSheet { analyticsButton.assertIsDisplayed() } + } + step("Assert 'Copy address' button is displayed") { + onTokenActionsBottomSheet { copyAddressButton.assertIsDisplayed() } + } + step("Assert 'Receive' button is displayed") { + onTokenActionsBottomSheet { receiveButton.assertIsDisplayed() } + } + step("Assert 'Send' button is displayed") { + onTokenActionsBottomSheet { sendButton.assertIsDisplayed() } + } + step("Assert 'Swap' button is displayed") { + onTokenActionsBottomSheet { swapButton.assertIsDisplayed() } + } + step("Assert 'Buy' button is displayed") { + onTokenActionsBottomSheet { buyButton.assertIsDisplayed() } + } + step("Assert 'Sell' button is displayed") { + onTokenActionsBottomSheet { sellButton.assertIsDisplayed() } + } + step("Assert 'Hide token' button is displayed") { + onTokenActionsBottomSheet { hideTokenButton.assertIsDisplayed() } + } + } + } + + @AllureId("84") + @DisplayName("Action buttons (long tap): check 'Copy address' button") + @Test + fun clickOnCopyAddressButtonTest() { + val tokenTitle = "Bitcoin" + val balance = TOTAL_BALANCE + val bitcoinAddress = BITCOIN_ADDRESS + + setupHooks( + additionalBeforeSection = { + clearClipboard() + }, + additionalAfterSection = { + clearClipboard() + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Copy address' button is displayed") { + onTokenActionsBottomSheet { copyAddressButton.assertIsDisplayed() } + } + step("Click on 'Copy address' button") { + onTokenActionsBottomSheet { copyAddressButton.performClick() } + } + step("Assert snack bar message is displayed") { + onMainScreen { snackbarCopiedAddressMessage.assertIsDisplayed() } + } + step("Check clipboard has '$tokenTitle' address '$bitcoinAddress'") { + waitForIdle() + assertClipboardTextEquals(expected = bitcoinAddress) + } + } + } + + @AllureId("82") + @DisplayName("Action buttons (long tap): check 'Buy' button") + @Test + fun clickOnBuyButtonTest() { + val tokenTitle = "Bitcoin" + val tokenSymbol = "BTC" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Buy' button is displayed") { + onTokenActionsBottomSheet { buyButton.assertIsDisplayed() } + } + step("Click on 'Buy' button") { + onTokenActionsBottomSheet { buyButton.performClick() } + } + step("Click on 'Confirm' button in 'Dialog'") { + waitForIdle() + onDialog { confirmButton.clickWithAssertion() } + } + step("Assert top app bar title contains '$tokenTitle'") { + onBuyTokenDetailsScreen { topBarTitle.assertTextContains("Buy $tokenTitle") } + } + step("Assert fiat currency text field is displayed") { + onBuyTokenDetailsScreen { fiatAmountTextField.assertIsDisplayed() } + } + step("Assert fiat currency icon is displayed") { + onBuyTokenDetailsScreen { fiatCurrencyIcon.assertIsDisplayed() } + } + step("Assert token amount field is displayed") { + onBuyTokenDetailsScreen { tokenAmountField.assertTextContains(tokenSymbol, substring = true) } + } + step("Assert 'Continue' button") { + onBuyTokenDetailsScreen { continueButton.assertIsDisplayed() } + } + } + } + + @AllureId("87") + @DisplayName("Action buttons (long tap): check 'Swap' button") + @Test + fun clickOnSwapButtonTest() { + val tokenTitle = "Ethereum" + val tokenSymbol = "ETH" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Swap' button is displayed") { + onTokenActionsBottomSheet { swapButton.assertIsDisplayed() } + } + step("Click on 'Swap' button") { + onTokenActionsBottomSheet { 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("83") + @DisplayName("Action buttons (long tap): check 'Send' button") + @Test + fun clickOnSendButtonTest() { + val tokenTitle = "Ethereum" + val tokenSymbol = "ETH" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Send' button is displayed") { + onTokenActionsBottomSheet { sendButton.assertIsDisplayed() } + } + step("Click on 'Send' button") { + onTokenActionsBottomSheet { sendButton.performClick() } + } + step("Assert amount input text field contains token symbol: '$tokenSymbol'") { + onSendScreen { + amountInputTextField.assertTextContains(value = tokenSymbol, substring = true) + } + } + } + } + + @AllureId("86") + @DisplayName("Action buttons (long tap): check 'Receive' button") + @Test + fun clickOnReceiveButtonTest() { + val tokenTitle = "Bitcoin" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Receive' button is displayed") { + onTokenActionsBottomSheet { receiveButton.assertIsDisplayed() } + } + step("Click on 'Receive' button") { + onTokenActionsBottomSheet { receiveButton.performClick() } + } + 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() } + } + 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() } + } + } + } + + @AllureId("85") + @DisplayName("Action buttons (long tap): check 'Sell' button") + @Test + fun clickOnSellButtonTest() { + val tokenTitle = "Ethereum" + val tokenSymbol = "ETH" + val balance = TOTAL_BALANCE + val url = "sell.moonpay.com" + val useWithoutAccount = "Use without an account" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Receive' button is displayed") { + onTokenActionsBottomSheet { sellButton.assertIsDisplayed() } + } + step("Click on 'Receive' button") { + onTokenActionsBottomSheet { sellButton.performClick() } + } + step("Assert Chrome Browser is opened") { + ChromeBrowserPageObject { assertChromeIsOpened() } + } + if (ChromeBrowserPageObject.isElementWithTextExists(useWithoutAccount)) { + step("Click on '$useWithoutAccount' button on Chrome browser") { + ChromeBrowserPageObject { clickOnElementWithText(useWithoutAccount) } + } + } + step("Assert url contains: '$url'") { + ChromeBrowserPageObject { assertUrlContains(url) } + } + step("Assert token symbol '$tokenSymbol' is displayed") { + ChromeBrowserPageObject { assertElementWithTextExists(tokenSymbol) } + } + } + } + + @AllureId("77") + @DisplayName("Action buttons (long tap): assert 'Sell' button is not displayed if token doesn't support it") + @Test + fun assertSellButtonIsNotDisplayedTest() { + val tokenTitle = "Bitcoin" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Sell' button is not displayed") { + onTokenActionsBottomSheet { sellButton.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index 6fbc4b4812..756f9ea213 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -162,7 +162,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { } } step("Click 'Hide token' button") { - onBottomSheet { hideButton.clickWithAssertion() } + onTokenActionsBottomSheet { hideTokenButton.clickWithAssertion() } } step("Click 'Hide' button in dialog") { onDialog { 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 58e979c7dc..ce88071554 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -67,7 +67,11 @@ internal class DefaultTangemPayStorage @Inject constructor( secureStorage.get(createOrderIdKey(customerWalletAddress))?.decodeToString(throwOnInvalidSequence = true) } - override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) { + 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)) } 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 34f9a1101c..7c946ee340 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 @@ -49,8 +49,12 @@ internal object AccountDomainModule { @Singleton fun provideRecoverCryptoPortfolioUseCase( accountsCRUDRepository: AccountsCRUDRepository, + mainAccountTokensMigration: MainAccountTokensMigration, ): RecoverCryptoPortfolioUseCase { - return RecoverCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository) + return RecoverCryptoPortfolioUseCase( + crudRepository = accountsCRUDRepository, + mainAccountTokensMigration = mainAccountTokensMigration, + ) } @Provides 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 0f7e759701..d1749cce71 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 @@ -16,7 +16,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.* -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase @@ -454,21 +454,17 @@ internal object WalletsDomainModule { @Provides @Singleton - fun provideYieldSupplyApyFlowUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyApyFlowUseCase { + fun provideYieldSupplyApyFlowUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyApyFlowUseCase { return YieldSupplyApyFlowUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } @Provides @Singleton - fun provideYieldSupplyApyUpdateUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyApyUpdateUseCase { + fun provideYieldSupplyApyUpdateUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyApyUpdateUseCase { return YieldSupplyApyUpdateUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index 70ce92ea14..839504ffbe 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -4,7 +4,7 @@ import com.tangem.domain.blockaid.BlockAidGasEstimate import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.yield.supply.YieldSupplyErrorResolver -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository import com.tangem.domain.yield.supply.usecase.* import dagger.Module @@ -82,30 +82,54 @@ internal object YieldSupplyDomainModule { @Provides @Singleton fun provideYieldSupplyGetTokenStatusUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, + yieldSupplyRepository: YieldSupplyRepository, ): YieldSupplyGetTokenStatusUseCase { return YieldSupplyGetTokenStatusUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } @Provides @Singleton - fun provideYieldSupplyGetApyUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyGetApyUseCase { + fun provideYieldSupplyGetApyUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyGetApyUseCase { return YieldSupplyGetApyUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } @Provides @Singleton - fun provideYieldSupplyGetChartUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyGetChartUseCase { + fun provideYieldSupplyGetChartUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyGetChartUseCase { return YieldSupplyGetChartUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyIsAvailableUseCase( + yieldSupplyRepository: YieldSupplyRepository, + ): YieldSupplyIsAvailableUseCase { + return YieldSupplyIsAvailableUseCase( + yieldSupplyRepository = yieldSupplyRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyActivateUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyActivateUseCase { + return YieldSupplyActivateUseCase( + yieldSupplyRepository = yieldSupplyRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyDeactivateUseCase( + yieldSupplyRepository: YieldSupplyRepository, + ): YieldSupplyDeactivateUseCase { + return YieldSupplyDeactivateUseCase( + yieldSupplyRepository = yieldSupplyRepository, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt index 3a83c00641..642cdea9ac 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt @@ -160,4 +160,6 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Pepecoin, PepecoinTestnet -> null Hyperliquid, HyperliquidTestnet -> null Quai, QuaiTestnet -> null + Linea, LineaTestnet -> null + ArbitrumNova -> null } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 900514a667..c858ffedc0 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 @@ -2,6 +2,7 @@ package com.tangem.tap.routing.utils import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.models.PortfolioId import com.tangem.domain.qrscanning.models.SourceType import com.tangem.feature.qrscanning.QrScanningComponent import com.tangem.feature.referral.api.ReferralComponent @@ -12,6 +13,7 @@ import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.createwalletselection.CreateWalletSelectionComponent +import com.tangem.features.createwalletstart.CreateWalletStartComponent import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.home.api.HomeComponent @@ -19,6 +21,7 @@ import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.tokenlist.MarketsTokenListComponent @@ -97,6 +100,7 @@ internal class ChildFactory @Inject constructor( private val usedeskComponentFactory: UsedeskComponent.Factory, private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory, private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory, + private val createWalletStartComponentFactory: CreateWalletStartComponent.Factory, private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory, private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory, private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory, @@ -140,9 +144,15 @@ internal class ChildFactory @Inject constructor( AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES } + val mode = when (val portfolio = route.portfolioId) { + is PortfolioId.Account -> ManageTokensMode.Account(portfolio.accountId) + is PortfolioId.Wallet -> ManageTokensMode.Wallet(portfolio.userWalletId) + null -> ManageTokensMode.None + } + createComponentChild( context = context, - params = ManageTokensComponent.Params(route.userWalletId, source), + params = ManageTokensComponent.Params(mode, source), componentFactory = manageTokensComponentFactory, ) } @@ -200,7 +210,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = OnrampComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, cryptoCurrency = route.currency, source = route.source, shouldLaunchSepa = route.shouldLaunchSepa, @@ -274,7 +284,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = TokenDetailsComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param + userWalletId = route.userWalletId, currency = route.currency, ), componentFactory = tokenDetailsComponentFactory, @@ -284,7 +294,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = StakingComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, cryptoCurrencyId = route.cryptoCurrencyId, yieldId = route.yieldId, ), @@ -297,7 +307,7 @@ internal class ChildFactory @Inject constructor( params = SwapComponent.Params( currencyFrom = route.currencyFrom, currencyTo = route.currencyTo, - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, isInitialReverseOrder = route.isInitialReverseOrder, screenSource = route.screenSource, ), @@ -308,7 +318,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = SendComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, currency = route.currency, transactionId = route.transactionId, amount = route.amount, @@ -468,6 +478,19 @@ internal class ChildFactory @Inject constructor( componentFactory = chooseManagedTokensComponentFactory, ) } + is AppRoute.CreateWalletStart -> { + val mode = when (route.mode) { + AppRoute.CreateWalletStart.Mode.ColdWallet -> CreateWalletStartComponent.Mode.ColdWallet + AppRoute.CreateWalletStart.Mode.HotWallet -> CreateWalletStartComponent.Mode.HotWallet + } + createComponentChild( + context = context, + params = CreateWalletStartComponent.Params( + mode = mode, + ), + componentFactory = createWalletStartComponentFactory, + ) + } is AppRoute.CreateWalletSelection -> { createComponentChild( context = context, 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 d4566f6a58..cdba5c18f3 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 @@ -51,50 +51,25 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class CurrencyDetails( - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val currency: CryptoCurrency, - ) : AppRoute(path = "/currency_details/${portfolioId.stringValue}/${currency.id.value}") { - companion object { - operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) = CurrencyDetails( - portfolioId = PortfolioId(userWalletId), - currency = currency, - ) - } - } + ) : AppRoute(path = "/currency_details/${userWalletId.stringValue}/${currency.id.value}") @Serializable data class Send( - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val currency: CryptoCurrency, val transactionId: String? = null, val amount: String? = null, val tag: String? = null, val destinationAddress: String? = null, ) : AppRoute( - path = "/send/${portfolioId.stringValue}/${currency.id.value}?" + + path = "/send/${userWalletId.stringValue}/${currency.id.value}?" + "&$transactionId" + "&$amount" + "&$tag" + "&$destinationAddress", - ) { - companion object { - operator fun invoke( - userWalletId: UserWalletId, - currency: CryptoCurrency, - transactionId: String? = null, - amount: String? = null, - tag: String? = null, - destinationAddress: String? = null, - ) = Send( - portfolioId = PortfolioId(userWalletId), - currency = currency, - transactionId = transactionId, - amount = amount, - tag = tag, - destinationAddress = destinationAddress, - ) - } - } + ) @Serializable data class Details( @@ -147,8 +122,8 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class ManageTokens( val source: Source, - val userWalletId: UserWalletId? = null, - ) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/$userWalletId") { + val portfolioId: PortfolioId? = null, + ) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/${portfolioId?.stringValue}") { enum class Source { STORIES, @@ -199,51 +174,26 @@ sealed class AppRoute(val path: String) : Route { data class Swap( val currencyFrom: CryptoCurrency, val currencyTo: CryptoCurrency? = null, - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val isInitialReverseOrder: Boolean = false, val screenSource: String, ) : AppRoute( path = "/swap" + "/${currencyFrom.id.value}" + "/${currencyTo?.id?.value}" + - "/${portfolioId.stringValue}" + + "/${userWalletId.stringValue}" + "/$isInitialReverseOrder", - ) { - companion object { - operator fun invoke( - userWalletId: UserWalletId, - currencyFrom: CryptoCurrency, - currencyTo: CryptoCurrency? = null, - isInitialReverseOrder: Boolean = false, - screenSource: String, - ) = Swap( - portfolioId = PortfolioId(userWalletId), - currencyFrom = currencyFrom, - currencyTo = currencyTo, - isInitialReverseOrder = isInitialReverseOrder, - screenSource = screenSource, - ) - } - } + ) @Serializable data object AppCurrencySelector : AppRoute(path = "/app_currency_selector") @Serializable data class Staking( - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val cryptoCurrencyId: CryptoCurrency.ID, val yieldId: String, - ) : AppRoute(path = "/staking/${portfolioId.stringValue}/${cryptoCurrencyId.value}/$yieldId") { - companion object { - operator fun invoke(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, yieldId: String) = - Staking( - portfolioId = PortfolioId(userWalletId), - cryptoCurrencyId = cryptoCurrencyId, - yieldId = yieldId, - ) - } - } + ) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrencyId.value}/$yieldId") @Serializable data class PushNotification( @@ -287,25 +237,11 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Onramp( val source: OnrampSource, - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val currency: CryptoCurrency, val shouldLaunchSepa: Boolean = false, - ) : AppRoute(path = "/onramp/${portfolioId.stringValue}/${currency.symbol}"), RouteBundleParams { + ) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) - - companion object { - operator fun invoke( - source: OnrampSource, - userWalletId: UserWalletId, - currency: CryptoCurrency, - launchSepa: Boolean = false, - ) = Onramp( - source = source, - portfolioId = PortfolioId(userWalletId), - currency = currency, - shouldLaunchSepa = launchSepa, - ) - } } @Serializable @@ -375,6 +311,16 @@ sealed class AppRoute(val path: String) : Route { @Serializable object CreateWalletSelection : AppRoute(path = "/create_wallet_selection") + @Serializable + data class CreateWalletStart( + val mode: Mode, + ) : AppRoute(path = "/create_wallet_start") { + enum class Mode { + ColdWallet, + HotWallet, + } + } + @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountLabel.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountLabel.kt new file mode 100644 index 0000000000..51f13b848f --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountLabel.kt @@ -0,0 +1,53 @@ +package com.tangem.common.ui.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.text.TextStyle +import androidx.compose.ui.unit.dp +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.res.TangemTheme + +/** + * Displays account name with icon + * + * @param name account name + * @param icon portfolio account icon model + * @param iconSize portfolio account icon size + * @param nameStyle account name style + * @param nameColor account name color + * @see AccountIcon + */ +@Composable +fun AccountLabel( + name: TextReference, + icon: CryptoPortfolioIconUM, + iconSize: AccountIconSize, + modifier: Modifier = Modifier, + nameStyle: TextStyle = TangemTheme.typography.subtitle2, + nameColor: Color = TangemTheme.colors.text.tertiary, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AccountIcon( + name = name, + icon = icon, + size = iconSize, + ) + Text( + text = name.resolveReference(), + style = nameStyle, + color = nameColor, + maxLines = 1, + ) + } +} \ No newline at end of file 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 276468c38c..0f29f40095 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 @@ -33,7 +33,6 @@ class AccountPortfolioItemUMConverter( endIcon = endIcon, onClick = { onClick(value.accountId) }, imageState = getImageState(value), - label = null, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt index fdd122631d..f0fa604881 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt @@ -13,21 +13,17 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountScreenClickIntentsStub import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData -import com.tangem.common.ui.amountScreen.ui.amountField import com.tangem.common.ui.amountScreen.ui.amountFieldV2 -import com.tangem.common.ui.amountScreen.ui.buttons import com.tangem.core.ui.res.TangemThemePreview /** * Amount screen with field * @param amountState amount state - * @param isBalanceHidden flag hidden balances * @param clickIntents amount screen clicks */ @Composable fun AmountScreenContent( amountState: AmountState, - isBalanceHidden: Boolean, clickIntents: AmountScreenClickIntents, modifier: Modifier = Modifier, extraContent: (@Composable () -> Unit)? = null, @@ -38,32 +34,17 @@ fun AmountScreenContent( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - if (amountState.isRedesignEnabled) { - amountFieldV2( - amountState = amountState, - onValueChange = clickIntents::onAmountValueChange, - onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, - onCurrencyChange = clickIntents::onCurrencyChangeClick, - onMaxAmountClick = clickIntents::onMaxValueClick, - ) - if (extraContent != null) { - item("EXTRA_CONTENT_KEY") { - extraContent() - } + amountFieldV2( + amountState = amountState, + onValueChange = clickIntents::onAmountValueChange, + onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, + onCurrencyChange = clickIntents::onCurrencyChangeClick, + onMaxAmountClick = clickIntents::onMaxValueClick, + ) + if (extraContent != null) { + item("EXTRA_CONTENT_KEY") { + extraContent() } - } else if (amountState is AmountState.Data) { - amountField( - amountState = amountState, - isBalanceHidden = isBalanceHidden, - onValueChange = clickIntents::onAmountValueChange, - onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, - ) - buttons( - segmentedButtonConfig = amountState.segmentedButtonConfig, - clickIntents = clickIntents, - isSegmentedButtonsEnabled = amountState.isSegmentedButtonsEnabled, - selectedButton = amountState.selectedButton, - ) } } } @@ -78,7 +59,6 @@ private fun SendAmountContentPreview( TangemThemePreview { AmountScreenContent( amountState = amountState, - isBalanceHidden = false, clickIntents = AmountScreenClickIntentsStub, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt index 7406013c29..0faf2844b8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt @@ -38,7 +38,6 @@ class AmountCurrencyTransformer( keyboardType = KeyboardType.Number, ), ), - selectedButton = prevState.segmentedButtonConfig.indexOfFirst { it.isFiat == value }, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt index f96fa69bd0..783682376d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt @@ -70,10 +70,9 @@ class AmountReduceByTransformer( error = when { isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance) isLessThanMinimumIfProvided -> { - val minimumAmount = minimumTransactionAmount - ?.amount - ?.format { crypto(cryptoCurrencyStatus.currency) } - .orEmpty() + val minimumAmount = minimumTransactionAmount.amount.format { + crypto(cryptoCurrencyStatus.currency) + } resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt index 0afd5d8bc5..864db79015 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt @@ -64,10 +64,9 @@ class AmountReduceToTransformer( error = when { isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance) isLessThanMinimumIfProvided -> { - val minimumAmount = minimumTransactionAmount - ?.amount - ?.format { crypto(cryptoCurrencyStatus.currency) } - .orEmpty() + val minimumAmount = minimumTransactionAmount.amount.format { + crypto(cryptoCurrencyStatus.currency) + } resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index 36f37a67ab..cdcd0771cc 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -1,104 +1,35 @@ package com.tangem.common.ui.amountScreen.converters -import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter -import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverterV2 import com.tangem.common.ui.amountScreen.models.AmountParameters -import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.orMaskWithStars +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.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.converter.Converter -import com.tangem.utils.isNullOrZero -import kotlinx.collections.immutable.persistentListOf - -/** - * Converts initial [String] to [AmountState] - * - * @property clickIntents amount screen clicks - * @property appCurrencyProvider selected app currency provider - * @property maxEnterAmount max enter amount data - * @property cryptoCurrencyStatusProvider current cryptocurrency status provider - * @property iconStateConverter currency icon converter - */ -@Deprecated("Use AmountStateConverterV2") -class AmountStateConverter( - private val clickIntents: AmountScreenClickIntents, - private val appCurrencyProvider: Provider, - private val cryptoCurrencyStatusProvider: Provider, - private val maxEnterAmount: EnterAmountBoundary, - private val iconStateConverter: CryptoCurrencyToIconStateConverter, -) : Converter { - - private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { - AmountFieldConverter( - clickIntents = clickIntents, - cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, - appCurrencyProvider = appCurrencyProvider, - ) - } - - override fun convert(value: AmountParameters): AmountState { - val appCurrency = appCurrencyProvider() - val status = cryptoCurrencyStatusProvider() - val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) } - val crypto = maxEnterAmount.amount.format { crypto(status.currency) } - val hasNoFeeRate = status.value.fiatRate.isNullOrZero() - - return AmountState.Data( - title = value.title, - availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)), - availableBalanceCrypto = stringReference(crypto), - availableBalanceFiat = stringReference(fiat), - tokenName = stringReference(status.currency.name), - tokenIconState = iconStateConverter.convert(status), - amountTextField = amountFieldConverter.convert(value.value), - isPrimaryButtonEnabled = false, - appCurrency = appCurrency, - segmentedButtonConfig = persistentListOf( - AmountSegmentedButtonsConfig( - title = stringReference(status.currency.symbol), - iconState = iconStateConverter.convertCustom( - value = status, - forceGrayscale = hasNoFeeRate, - showCustomTokenBadge = false, - ), - isFiat = false, - ), - AmountSegmentedButtonsConfig( - title = stringReference(appCurrency.code), - iconUrl = appCurrency.iconSmallUrl, - isFiat = true, - ), - ), - isSegmentedButtonsEnabled = !hasNoFeeRate, - selectedButton = 0, - isRedesignEnabled = false, - ) - } -} /** * Converts initial [String] to [AmountState] * * @property clickIntents amount screen clicks * @property appCurrency selected app currency - * @property maxEnterAmount max enter amount data * @property cryptoCurrencyStatus current cryptocurrency status + * @property maxEnterAmount max enter amount data * @property iconStateConverter currency icon converter * @property isBalanceHidden is balance hidden status */ @Suppress("LongParameterList") -class AmountStateConverterV2( +class AmountStateConverter( private val clickIntents: AmountScreenClickIntents, private val appCurrency: AppCurrency, private val cryptoCurrencyStatus: CryptoCurrencyStatus, @@ -108,7 +39,7 @@ class AmountStateConverterV2( ) : Converter { private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { - AmountFieldConverterV2( + AmountFieldConverter( clickIntents = clickIntents, cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrency = appCurrency, @@ -118,19 +49,13 @@ class AmountStateConverterV2( override fun convert(value: AmountParameters): AmountState { val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) } val crypto = maxEnterAmount.amount.format { crypto(cryptoCurrencyStatus.currency) } - val noFeeRate = cryptoCurrencyStatus.value.fiatRate.isNullOrZero() if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { - return AmountState.Empty(isRedesignEnabled = true) + return AmountState.Empty } return AmountState.Data( title = value.title, - availableBalance = combinedReference( - stringReference(crypto), - stringReference(" $DOT "), - stringReference(fiat), - ).orMaskWithStars(isBalanceHidden), availableBalanceCrypto = stringReference(crypto).orMaskWithStars(isBalanceHidden), availableBalanceFiat = if (isBalanceHidden) { TextReference.EMPTY @@ -145,25 +70,6 @@ class AmountStateConverterV2( amountTextField = amountFieldConverter.convert(value.value), isPrimaryButtonEnabled = false, appCurrency = appCurrency, - segmentedButtonConfig = persistentListOf( - AmountSegmentedButtonsConfig( - title = stringReference(cryptoCurrencyStatus.currency.symbol), - iconState = iconStateConverter.convertCustom( - value = cryptoCurrencyStatus, - forceGrayscale = noFeeRate, - showCustomTokenBadge = false, - ), - isFiat = false, - ), - AmountSegmentedButtonsConfig( - title = stringReference(appCurrency.code), - iconUrl = appCurrency.iconSmallUrl, - isFiat = true, - ), - ), - isSegmentedButtonsEnabled = !noFeeRate, - selectedButton = 0, - isRedesignEnabled = true, ) } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt index 53bdef9c57..21a6f4e7f5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt @@ -2,7 +2,10 @@ package com.tangem.common.ui.amountScreen.converters.field import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.orMaskWithStars +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 @@ -32,14 +35,7 @@ class AmountBoundaryUpdateTransformer( val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) } val crypto = maxEnterAmount.amount.format { crypto(cryptoCurrencyStatus.currency) } - val availableBalance = combinedReference( - stringReference(crypto), - stringReference(" $DOT "), - stringReference(fiat), - ) - return prevState.copy( - availableBalance = availableBalance.orMaskWithStars(isBalanceHidden), availableBalanceCrypto = stringReference(crypto).orMaskWithStars(isBalanceHidden), availableBalanceFiat = if (isBalanceHidden) { TextReference.EMPTY diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt index f0baf3a1e9..411de67feb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt @@ -13,75 +13,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.tokens.model.convertToAmount -import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import com.tangem.utils.isNullOrZero import java.math.BigDecimal -/** - * Converts initial [String] to [AmountFieldModel] - * - * @property clickIntents amount screen clicks - * @property appCurrencyProvider selected app currency provider - * @property cryptoCurrencyStatusProvider current cryptocurrency status provider - */ -@Deprecated("Use AmountFieldConverterV2") -class AmountFieldConverter( - private val clickIntents: AmountScreenClickIntents, - private val cryptoCurrencyStatusProvider: Provider, - private val appCurrencyProvider: Provider, -) : Converter { - - override fun convert(value: String): AmountFieldModel { - val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() - val cryptoDecimal = value.toBigDecimalOrNull() ?: BigDecimal.ZERO - val cryptoAmount = cryptoDecimal.convertToAmount(cryptoCurrencyStatus.currency) - val fiatRate = cryptoCurrencyStatus.value.fiatRate - val (fiatValue, fiatDecimal) = when { - fiatRate.isNullOrZero() -> "" to null - value.isEmpty() -> "" to BigDecimal.ZERO - else -> { - val fiatDecimal = fiatRate?.multiply(cryptoDecimal) - val fiatValue = fiatDecimal?.parseBigDecimal(FIAT_DECIMALS).orEmpty() - fiatValue to fiatDecimal - } - } - val isDoneActionEnabled = !cryptoDecimal.isNullOrZero() - return AmountFieldModel( - value = value, - fiatValue = fiatValue, - onValueChange = clickIntents::onAmountValueChange, - keyboardOptions = KeyboardOptions( - imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None, - keyboardType = KeyboardType.Number, - ), - keyboardActions = KeyboardActions( - onDone = { clickIntents.onAmountNext() }, - ), - isFiatValue = false, - cryptoAmount = cryptoAmount, - fiatAmount = getAppCurrencyAmount(fiatDecimal, appCurrencyProvider()), - isError = false, - isWarning = false, - error = TextReference.EMPTY, - isFiatUnavailable = fiatRate == null, - isValuePasted = false, - onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, - ) - } - - private fun getAppCurrencyAmount(fiatValue: BigDecimal?, appCurrency: AppCurrency) = Amount( - currencySymbol = appCurrency.symbol, - value = fiatValue, - decimals = FIAT_DECIMALS, - type = AmountType.FiatType(appCurrency.code), - ) - - private companion object { - private const val FIAT_DECIMALS = 2 - } -} - /** * Converts initial [String] to [AmountFieldModel] * @@ -89,7 +24,7 @@ class AmountFieldConverter( * @property appCurrency selected app currency * @property cryptoCurrencyStatus current cryptocurrency status */ -class AmountFieldConverterV2( +class AmountFieldConverter( private val clickIntents: AmountScreenClickIntents, private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrency: AppCurrency, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt index a8c20c0745..db245b6b30 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt @@ -54,7 +54,7 @@ class AmountFieldSetMaxAmountTransformer( isError = isLessThanMinimumIfProvided, error = when { isLessThanMinimumIfProvided -> { - val minimumAmount = minAmount?.amount.format { crypto(cryptoCurrencyStatus.currency) } + val minimumAmount = minAmount.amount.format { crypto(cryptoCurrencyStatus.currency) } resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountSegmentedButtonsConfig.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountSegmentedButtonsConfig.kt deleted file mode 100644 index 23469c4c03..0000000000 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountSegmentedButtonsConfig.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.common.ui.amountScreen.models - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.extensions.TextReference - -/** - * Segmented buttons config - * - * @param title button title - * @param iconState currency icon state - * @param iconUrl currency icon url - * @param isFiat is fiat currency - */ -@Immutable -data class AmountSegmentedButtonsConfig( - val title: TextReference, - val iconState: CurrencyIconState? = null, - val iconUrl: String? = null, - val isFiat: Boolean, -) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt index a063b984f5..d207f14e5a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Stable import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.appcurrency.model.AppCurrency -import kotlinx.collections.immutable.PersistentList import java.math.BigDecimal /** Model for amount state */ @@ -12,18 +11,13 @@ import java.math.BigDecimal sealed class AmountState { abstract val isPrimaryButtonEnabled: Boolean - abstract val isRedesignEnabled: Boolean /** * @param isPrimaryButtonEnabled indicates if next state button enabled * @param title title - * @param availableBalance user crypto currency balance with fiat balance * @param availableBalanceCrypto user crypto currency balance in crypto * @param availableBalanceFiat user crypto currency balance in fiat * @param tokenIconState crypto currency icon state - * @param segmentedButtonConfig currency switcher config - * @param selectedButton selected currency index - * @param isSegmentedButtonsEnabled indicates if currency switches is enabled * @param amountTextField amount field state * @param appCurrency app currency * @param isEditingDisabled indicated whether amount is editable @@ -32,17 +26,11 @@ sealed class AmountState { */ data class Data( override val isPrimaryButtonEnabled: Boolean, - override val isRedesignEnabled: Boolean, val title: TextReference, - @Deprecated("Remove with SEND_REDESIGNED toggle") - val availableBalance: TextReference, val availableBalanceCrypto: TextReference, val availableBalanceFiat: TextReference, val tokenName: TextReference, val tokenIconState: CurrencyIconState, - val segmentedButtonConfig: PersistentList, - val selectedButton: Int, - val isSegmentedButtonsEnabled: Boolean, val amountTextField: AmountFieldModel, val appCurrency: AppCurrency, val isEditingDisabled: Boolean = false, @@ -50,8 +38,7 @@ sealed class AmountState { val isIgnoreReduce: Boolean = false, ) : AmountState() - data class Empty( - override val isPrimaryButtonEnabled: Boolean = false, - override val isRedesignEnabled: Boolean, - ) : AmountState() + data object Empty : AmountState() { + override val isPrimaryButtonEnabled: Boolean = false + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt index 98f7c1e21d..0624a2c89d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt @@ -3,7 +3,6 @@ package com.tangem.common.ui.amountScreen.preview import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import com.tangem.common.ui.amountScreen.models.AmountFieldModel -import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference @@ -12,31 +11,18 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import com.tangem.utils.StringsSigns -import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal object AmountStatePreviewData { - val emptyState = AmountState.Empty(isRedesignEnabled = true) + val emptyState = AmountState.Empty val amountState = AmountState.Data( isPrimaryButtonEnabled = false, title = stringReference("Family Wallet"), - availableBalance = stringReference("2 130,81231238 USDT • 2 129,12 \$)"), availableBalanceCrypto = stringReference("2 130,81231238 USDT"), availableBalanceFiat = stringReference("1 232 129,12 \$"), tokenIconState = CurrencyIconState.Loading, - segmentedButtonConfig = persistentListOf( - AmountSegmentedButtonsConfig( - title = stringReference("USDT"), - iconState = CurrencyIconState.Locked, - isFiat = false, - ), - AmountSegmentedButtonsConfig( - title = stringReference("USD"), - isFiat = true, - ), - ), appCurrency = AppCurrency.Default, tokenName = stringReference("Tether"), amountTextField = AmountFieldModel( @@ -65,12 +51,9 @@ object AmountStatePreviewData { isValuePasted = false, onValuePastedTriggerDismiss = {}, ), - isSegmentedButtonsEnabled = true, - selectedButton = 0, - isRedesignEnabled = false, ) - val amountWithValueState = amountState.copy( + private val amountWithValueState = amountState.copy( amountTextField = amountState.amountTextField.copy( value = "100.00", cryptoAmount = amountState.amountTextField.cryptoAmount.copy( @@ -84,16 +67,10 @@ object AmountStatePreviewData { ) val amountStateV2 = amountState.copy( - isRedesignEnabled = true, - availableBalance = stringReference("2 130,81231238 USDT • 2 129,12 \$)"), availableBalanceCrypto = stringReference("2 130,81231238 USDT"), availableBalanceFiat = stringReference(" ${StringsSigns.DOT} 1 232 129,12 $"), ) - val amountWithValueFiatState = amountWithValueState.copy( - amountTextField = amountWithValueState.amountTextField.copy(isFiatValue = false), - ) - val amountStateV2WithoutRates = amountState.copy( amountTextField = amountState.amountTextField.copy( fiatAmount = amountState.amountTextField.fiatAmount.copy( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt index 62fe6dc051..7cb45e5259 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt @@ -11,21 +11,22 @@ 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.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 com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData import com.tangem.core.ui.components.ResizableText +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.extensions.resolveReference 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.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.BaseAmountBlockTestTags @Composable fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDisabled: Boolean, onClick: () -> Unit) { @@ -59,7 +60,16 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) .padding(TangemTheme.dimens.spacing16), ) { - CurrencyIcon(state = amountState.tokenIconState) + Text( + text = amountState.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH(20.dp) + CurrencyIcon( + state = amountState.tokenIconState, + iconSize = 40.dp, + ) ResizableText( text = firstAmount, style = TangemTheme.typography.h2, @@ -68,8 +78,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis maxLines = 1, modifier = Modifier .fillMaxWidth() - .padding(top = TangemTheme.dimens.spacing24) - .testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), + .padding(top = TangemTheme.dimens.spacing24), ) Text( text = secondAmount, @@ -78,8 +87,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .padding(top = TangemTheme.dimens.spacing8) - .testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), + .padding(top = TangemTheme.dimens.spacing8), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt index 374ea3289c..68eca36591 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt @@ -9,6 +9,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 @@ -28,6 +29,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.uncapped import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.BaseAmountBlockTestTags @Composable fun AmountBlockV2( @@ -133,6 +135,7 @@ private fun AmountBlockV2( style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, maxLines = 1, + modifier = Modifier.testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -142,6 +145,7 @@ private fun AmountBlockV2( style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, maxLines = 1, + modifier = Modifier.testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), ) extraContent() } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt deleted file mode 100644 index 64d1c8f25c..0000000000 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.tangem.common.ui.amountScreen.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.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.draw.clip -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.testTag -import com.tangem.common.ui.R -import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig -import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons -import com.tangem.core.ui.components.currency.fiaticon.FiatIcon -import com.tangem.core.ui.components.currency.icon.CurrencyIcon -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.test.SendScreenTestTags -import kotlinx.collections.immutable.PersistentList - -private const val AMOUNT_BUTTONS_KEY = "amountButtonsKey" - -internal fun LazyListScope.buttons( - segmentedButtonConfig: PersistentList, - clickIntents: AmountScreenClickIntents, - isSegmentedButtonsEnabled: Boolean, - selectedButton: Int, -) { - item( - key = AMOUNT_BUTTONS_KEY, - ) { - val hapticFeedback = LocalHapticFeedback.current - Row { - if (segmentedButtonConfig.isNotEmpty()) { - SegmentedButtons( - modifier = Modifier - .weight(1f) - .height(TangemTheme.dimens.size40), - config = segmentedButtonConfig, - showIndication = false, - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - clickIntents.onCurrencyChangeClick(it.isFiat) - }, - initialSelectedItem = segmentedButtonConfig.getOrNull(selectedButton), - isEnabled = isSegmentedButtonsEnabled, - ) { - AmountCurrencyButton( - button = it, - isSegmentedButtonsEnabled = isSegmentedButtonsEnabled, - ) - } - } else { - SpacerWMax() - } - Text( - text = stringResourceSafe(R.string.send_max_amount), - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .padding(start = TangemTheme.dimens.spacing8) - .height(TangemTheme.dimens.size40) - .clip(shape = RoundedCornerShape(TangemTheme.dimens.radius26)) - .background(TangemTheme.colors.button.secondary) - .clickable { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - clickIntents.onMaxValueClick() - } - .padding( - vertical = TangemTheme.dimens.spacing10, - horizontal = TangemTheme.dimens.spacing34, - ) - .testTag(SendScreenTestTags.MAX_BUTTON), - ) - } - } -} - -@Composable -private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegmentedButtonsEnabled: Boolean) { - Row( - modifier = Modifier - .fillMaxSize() - .padding( - horizontal = TangemTheme.dimens.spacing10, - ) - .testTag(SendScreenTestTags.CURRENCY_BUTTON), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - val iconModifier = Modifier - .size(TangemTheme.dimens.size18) - .padding(horizontal = TangemTheme.dimens.spacing1) - if (button.isFiat) { - FiatIcon( - url = button.iconUrl, - size = TangemTheme.dimens.size18, - isGrayscale = !isSegmentedButtonsEnabled, - modifier = iconModifier.testTag(SendScreenTestTags.FIAT_ICON), - ) - } else if (button.iconState != null) { - CurrencyIcon( - state = button.iconState, - shouldDisplayNetwork = false, - modifier = iconModifier.testTag(SendScreenTestTags.CURRENCY_ICON), - ) - } - Text( - text = button.title.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.button, - modifier = Modifier - .padding( - start = TangemTheme.dimens.spacing8, - ), - ) - } -} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt deleted file mode 100644 index 848bd0ab36..0000000000 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.tangem.common.ui.amountScreen.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeightIn -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.Companion.BottomCenter -import androidx.compose.ui.Alignment.Companion.TopCenter -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDirection -import com.tangem.common.ui.amountScreen.models.AmountFieldModel -import com.tangem.core.ui.components.fields.AmountTextField -import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference -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.res.TangemTheme -import com.tangem.core.ui.test.SendScreenTestTags -import com.tangem.core.ui.utils.rememberDecimalFormat -import kotlinx.coroutines.delay - -@Composable -internal fun AmountField( - amountField: AmountFieldModel, - appCurrencyCode: String, - onValueChange: (String) -> Unit, - onValuePastedTriggerDismiss: () -> Unit, -) { - val decimalFormat = rememberDecimalFormat() - val isFiatValue = amountField.isFiatValue - val currencyCode = if (isFiatValue) appCurrencyCode else null - val (primaryAmount, primaryValue) = if (isFiatValue) { - amountField.fiatAmount to amountField.fiatValue - } else { - amountField.cryptoAmount to amountField.value - } - val requester = remember { FocusRequester() } - val symbolColor = if (primaryValue.isBlank()) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1 - AmountTextField( - value = primaryValue, - decimals = primaryAmount.decimals, - visualTransformation = AmountVisualTransformation( - decimals = primaryAmount.decimals, - symbol = primaryAmount.currencySymbol, - currencyCode = currencyCode, - decimalFormat = decimalFormat, - symbolColor = symbolColor, - ), - onValueChange = onValueChange, - keyboardOptions = amountField.keyboardOptions, - keyboardActions = amountField.keyboardActions, - textStyle = TangemTheme.typography.h2.copy( - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - ), - isAutoResize = true, - isValuePasted = amountField.isValuePasted, - onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, - modifier = Modifier - .focusRequester(requester) - .padding( - top = TangemTheme.dimens.spacing24, - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - ) - .requiredHeightIn(min = TangemTheme.dimens.size32), - ) - - LaunchedEffect(key1 = Unit) { - delay(timeMillis = 200) - requester.requestFocus() - } - - AmountSecondary(amountField, appCurrencyCode) -} - -@Composable -private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: String) { - val secondaryAmount = if (amountField.isFiatValue) amountField.cryptoAmount else amountField.fiatAmount - Box( - modifier = Modifier - .fillMaxWidth() - .animateContentSize() - .padding( - top = TangemTheme.dimens.spacing8, - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - ), - ) { - val text = if (amountField.isFiatValue) { - secondaryAmount.value.format { crypto(secondaryAmount.currencySymbol, secondaryAmount.decimals) } - } else { - secondaryAmount.value.format { - fiat( - fiatCurrencySymbol = secondaryAmount.currencySymbol, - fiatCurrencyCode = appCurrencyCode, - ) - } - } - Text( - text = text, - style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr), - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - modifier = Modifier - .align(TopCenter) - .padding(bottom = TangemTheme.dimens.spacing32) - .testTag(SendScreenTestTags.SECONDARY_AMOUNT), - ) - AmountFieldError( - isError = amountField.isError, - isWarning = amountField.isWarning, - error = amountField.error, - modifier = Modifier - .align(BottomCenter) - .padding( - top = TangemTheme.dimens.spacing20, - bottom = TangemTheme.dimens.spacing12, - ), - ) - } -} - -@Composable -private fun AmountFieldError( - isError: Boolean, - isWarning: Boolean, - error: TextReference, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = isError || isWarning, - enter = fadeIn(), - exit = fadeOut(), - modifier = modifier, - ) { - val errorText = remember(this, error) { error } - val color = if (isError) TangemTheme.colors.text.warning else TangemTheme.colors.text.attention - Text( - text = errorText.resolveReference(), - style = TangemTheme.typography.caption2, - color = color, - textAlign = TextAlign.Center, - ) - } -} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt index 6fd563f173..ec354c5ff4 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt @@ -16,7 +16,6 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState @@ -25,7 +24,6 @@ import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -33,60 +31,6 @@ import com.tangem.core.ui.test.SendScreenTestTags private const val AMOUNT_FIELD_KEY = "amountFieldKey" -internal fun LazyListScope.amountField( - amountState: AmountState.Data, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, - onValueChange: (String) -> Unit, - onValuePastedTriggerDismiss: () -> Unit, -) { - item(key = AMOUNT_FIELD_KEY) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) - .background(TangemTheme.colors.background.action), - ) { - Text( - text = amountState.title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing14) - .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), - ) - - val balance = amountState.availableBalance.orMaskWithStars(isBalanceHidden).resolveReference() - AnimatedContent( - targetState = balance, - label = "Hide Balance Animation", - ) { - Text( - text = it, - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing2) - .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT), - ) - } - CurrencyIcon( - state = amountState.tokenIconState, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing32), - ) - AmountField( - amountField = amountState.amountTextField, - appCurrencyCode = amountState.appCurrency.code, - onValueChange = onValueChange, - onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, - ) - } - } -} - internal fun LazyListScope.amountFieldV2( amountState: AmountState, modifier: Modifier = Modifier, @@ -118,6 +62,7 @@ internal fun LazyListScope.amountFieldV2( text = amountState.title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), ) } AmountFieldV2( @@ -175,7 +120,8 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi indication = ripple(), onClick = onMaxAmountClick, ) - .padding(horizontal = 12.dp, vertical = 4.dp), + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag(SendScreenTestTags.MAX_BUTTON), ) } } @@ -183,46 +129,53 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi @Composable private fun AmountInfoMain(amountUM: AmountState, modifier: Modifier = Modifier) { AnimatedContent( - targetState = amountUM !is AmountState.Data, + targetState = amountUM, modifier = modifier, - ) { isContent -> - if (isContent) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - TextShimmer( - style = TangemTheme.typography.subtitle2, - modifier = Modifier.width(56.dp), - ) - TextShimmer( - style = TangemTheme.typography.caption2, - modifier = Modifier.width(72.dp), - ) - } - } else { - val amountUM = amountUM as AmountState.Data - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = amountUM.tokenName.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - ) - Row { - EllipsisText( - text = amountUM.availableBalanceCrypto.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ellipsis = TextEllipsis.OffsetEnd(amountUM.amountTextField.cryptoAmount.currencySymbol.length), - modifier = Modifier.weight(1f, fill = false), + ) { currentAmount -> + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + when (currentAmount) { + is AmountState.Data -> { + val amountUM = amountUM as AmountState.Data + Text( + text = amountUM.tokenName.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + modifier = Modifier.testTag(SendScreenTestTags.TOKEN_NAME), ) - EllipsisText( - text = amountUM.availableBalanceFiat.resolveReference(), + Row { + EllipsisText( + text = amountUM.availableBalanceCrypto.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd( + amountUM.amountTextField.cryptoAmount.currencySymbol.length, + ), + modifier = Modifier + .weight(1f, fill = false) + .testTag(SendScreenTestTags.PRIMARY_AMOUNT), + ) + EllipsisText( + text = amountUM.availableBalanceFiat.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd( + amountUM.amountTextField.fiatAmount.currencySymbol.length, + ), + modifier = Modifier.testTag(SendScreenTestTags.SECONDARY_AMOUNT), + ) + } + } + AmountState.Empty -> { + TextShimmer( + style = TangemTheme.typography.subtitle2, + modifier = Modifier.width(56.dp), + ) + TextShimmer( style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ellipsis = TextEllipsis.OffsetEnd(amountUM.amountTextField.fiatAmount.currencySymbol.length), + modifier = Modifier.width(72.dp), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt index a52403de79..47233d7291 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt @@ -55,7 +55,6 @@ fun NavigationButtonsBlock( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - PreviousButton(state?.prevButton) NavigationPrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f)) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt index 59bab529ed..6191b4de9c 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt @@ -8,7 +8,6 @@ sealed class NavigationButtonsState { data class Data( val primaryButton: NavigationButton?, - val prevButton: NavigationButton?, val extraButtons: Pair?, val txUrl: String? = null, val onTextClick: (String) -> Unit, diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt index 76ebce30a8..6c5a8d5991 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt @@ -3,7 +3,6 @@ package com.tangem.common.ui.navigationButtons.preview import com.tangem.common.ui.R import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationButtonsState -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference internal object NavigationButtonsPreview { @@ -26,16 +25,6 @@ internal object NavigationButtonsPreview { onClick = {}, ) - private val prev = NavigationButton( - textReference = TextReference.EMPTY, - iconRes = R.drawable.ic_back_24, - isSecondary = true, - isIconVisible = true, - shouldShowProgress = false, - isEnabled = true, - onClick = {}, - ) - private val finished = NavigationButton( textReference = resourceReference(R.string.common_close), isSecondary = false, @@ -47,7 +36,6 @@ internal object NavigationButtonsPreview { val allButtons = NavigationButtonsState.Data( primaryButton = finished, - prevButton = prev, extraButtons = extraButtons, txUrl = "https://tangem.com", onTextClick = {}, diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index 0d23288ee3..de0d5e70fe 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -7,7 +7,9 @@ import com.tangem.core.ui.components.icons.IconTint 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.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 @@ -15,6 +17,8 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.utils.StringsSigns.DASH_SIGN @@ -35,10 +39,13 @@ import java.math.BigDecimal */ class TokenItemStateConverter( private val appCurrency: AppCurrency, + private val apyMap: Map = emptyMap(), private val iconStateProvider: (CryptoCurrencyStatus) -> CurrencyIconState = { CryptoCurrencyToIconStateConverter().convert(it) }, - private val titleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.TitleState = Companion::createTitleState, + private val titleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.TitleState = { + createTitleState(it, apyMap) + }, private val subtitleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.SubtitleState? = { createSubtitleState(it, appCurrency) }, @@ -144,7 +151,10 @@ class TokenItemStateConverter( private fun CryptoCurrencyStatus.getStakedBalance() = (value.yieldBalance as? YieldBalance.Data) ?.getTotalWithRewardsStakingBalance(blockchainId = currency.network.rawId).orZero() - private fun createTitleState(currencyStatus: CryptoCurrencyStatus): TokenItemState.TitleState { + private fun createTitleState( + currencyStatus: CryptoCurrencyStatus, + apyMap: Map, + ): TokenItemState.TitleState { return when (val value = currencyStatus.value) { is CryptoCurrencyStatus.Loading, is CryptoCurrencyStatus.MissedDerivation, @@ -158,14 +168,33 @@ class TokenItemStateConverter( is CryptoCurrencyStatus.NoQuote, is CryptoCurrencyStatus.NoAccount, -> { + val earnApyText = resolveEarnApy(currencyStatus, apyMap)?.let { apy -> + resourceReference( + R.string.yield_module_earn_badge, + wrappedList(apy), + ) + } TokenItemState.TitleState.Content( text = stringReference(currencyStatus.currency.name), hasPending = value.hasCurrentNetworkTransactions, + earnApy = earnApyText, ) } } } + private fun resolveEarnApy(cryptoCurrencyStatus: CryptoCurrencyStatus, apyMap: Map): String? { + if (apyMap.isEmpty()) return null + + val isYieldSupplyActive = (cryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded) + ?.yieldSupplyStatus?.isActive == true + if (isYieldSupplyActive) return null + + val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return null + + return apyMap[token.yieldSupplyKey()] + } + private fun createSubtitleState( currencyStatus: CryptoCurrencyStatus, appCurrency: AppCurrency, 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 fedba7d453..44b2fc91e9 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 @@ -36,9 +36,6 @@ import com.tangem.core.ui.components.TextShimmer 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.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.text.applyBladeBrush import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme @@ -76,8 +73,6 @@ fun UserWalletItem( balance = state.balance, ) - state.label?.let { Label(it) } - when (state.endIcon) { UserWalletItemUM.EndIcon.None -> Unit UserWalletItemUM.EndIcon.Arrow -> { @@ -94,6 +89,13 @@ fun UserWalletItem( contentDescription = null, ) } + UserWalletItemUM.EndIcon.Warning -> { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_alert_circle_24), + tint = TangemTheme.colors.icon.warning, + contentDescription = null, + ) + } } } } @@ -316,10 +318,7 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider { 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 ba418cea6b..98c0988741 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 @@ -2,7 +2,6 @@ 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.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.wallet.UserWalletId import javax.annotation.concurrent.Immutable @@ -17,12 +16,13 @@ data class UserWalletItemUM( val isEnabled: Boolean, val endIcon: EndIcon = EndIcon.None, val onClick: () -> Unit, - val label: LabelUM? = null, ) { + enum class EndIcon { None, Arrow, Checkmark, + Warning, } sealed class Balance { 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 11acf26497..8b0cb7f15f 100644 --- a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json +++ b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json @@ -27,10 +27,6 @@ "name": "alephium", "version": "5.21.0" }, - { - "name": "scroll", - "version": "undefined" - }, { "name": "zklink", "version": "undefined" 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 6ac5527c5b..ef565f5f96 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 @@ -43,7 +43,7 @@ internal class TangemPay( private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://api.paera.com/bff/", + baseUrl = "https://api.us.paera.com/bff/", headers = createHeaders(), ) 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 3b08465384..e838002aa8 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 @@ -156,7 +156,7 @@ interface TangemTechApi { @Path("walletId") walletId: String, @Header("If-Match") eTag: String, @Body body: SaveWalletAccountsResponse, - ): ApiResponse + ): ApiResponse @GET("/v1/wallets/{walletId}/accounts/archived") suspend fun getWalletArchivedAccounts( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt index 20aae40e90..6ed56e4ce7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt @@ -4,7 +4,7 @@ import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody -import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse import retrofit2.http.Body import retrofit2.http.GET @@ -15,13 +15,13 @@ import retrofit2.http.Query interface YieldSupplyApi { @GET("api/v1/yield/markets") - suspend fun getYieldMarkets(@Query("chainId") chainId: Int? = null): ApiResponse + suspend fun getYieldMarkets(@Query("chainId") chainId: String? = null): ApiResponse @GET("api/v1/yield/token/{chainId}/{tokenAddress}") suspend fun getYieldTokenStatus( @Path("chainId") chainId: Int, @Path("tokenAddress") tokenAddress: String, - ): ApiResponse + ): ApiResponse @GET("api/v1/yield/token/{chainId}/{tokenAddress}/chart") suspend fun getYieldTokenChart( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt index ac62a8c655..d52ede3a6d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt @@ -2,21 +2,9 @@ package com.tangem.datasource.api.tangemTech.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import java.math.BigDecimal @JsonClass(generateAdapter = true) data class YieldMarketsResponse( - @Json(name = "tokens") val marketDtos: List, + @Json(name = "tokens") val marketDtos: List, @Json(name = "lastUpdatedAt") val lastUpdated: String, -) { - - @JsonClass(generateAdapter = true) - data class MarketDto( - @Json(name = "tokenAddress") val tokenAddress: String? = null, - @Json(name = "tokenSymbol") val tokenSymbol: String? = null, - @Json(name = "tokenName") val tokenName: String? = null, - @Json(name = "apy") val apy: BigDecimal, - @Json(name = "isActive") val isActive: Boolean, - @Json(name = "chainId") val chainId: Int? = null, - ) -} \ No newline at end of file +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldTokenStatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldSupplyMarketTokenDto.kt similarity index 94% rename from core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldTokenStatusResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldSupplyMarketTokenDto.kt index 6788fc5292..a2d32540f9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldTokenStatusResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldSupplyMarketTokenDto.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass import java.math.BigDecimal @JsonClass(generateAdapter = true) -data class YieldTokenStatusResponse( +data class YieldSupplyMarketTokenDto( @Json(name = "tokenAddress") val tokenAddress: String? = null, @Json(name = "tokenSymbol") val tokenSymbol: String? = null, @Json(name = "tokenName") val tokenName: String? = null, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt index 3f36276519..4157c42c41 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt @@ -2,8 +2,37 @@ package com.tangem.datasource.api.tangemTech.models.account import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.tangem.datasource.utils.SerializeNulls @JsonClass(generateAdapter = true) data class SaveWalletAccountsResponse( - @Json(name = "accounts") val accounts: List, -) \ No newline at end of file + @Json(name = "accounts") val accounts: List, +) { + + @SerializeNulls + @JsonClass(generateAdapter = true) + data class AccountDTO( + @Json(name = "id") val id: String, + @Json(name = "name") val name: String?, + @Json(name = "derivation") val derivationIndex: Int, + @Json(name = "icon") val icon: String, + @Json(name = "iconColor") val iconColor: String, + ) + + companion object { + + operator fun invoke(accounts: List): SaveWalletAccountsResponse { + return SaveWalletAccountsResponse( + accounts = accounts.map { accountDto -> + AccountDTO( + id = accountDto.id, + name = accountDto.name, + derivationIndex = accountDto.derivationIndex, + icon = accountDto.icon, + iconColor = accountDto.iconColor, + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 37ea5507c5..1da93e6b44 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -9,6 +9,7 @@ import com.tangem.common.json.MoshiJsonConverter import com.tangem.datasource.api.common.adapter.* import com.tangem.datasource.local.config.providers.models.ProviderModel import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.datasource.utils.SerializeNullsFactory import com.tangem.domain.models.scan.serialization.* import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.domain.visa.model.VisaCardActivationStatus @@ -28,6 +29,7 @@ class MoshiModule { @NetworkMoshi fun provideNetworkMoshi(): Moshi { return Moshi.Builder() + .add(SerializeNullsFactory) .add( PolymorphicJsonAdapterFactory.of(ProviderModel::class.java, "type") .withSubtype(ProviderModel.Public::class.java, "public") diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt index 8b174ebbc1..179051f898 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt @@ -4,11 +4,11 @@ import android.content.Context import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes -import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -34,7 +34,7 @@ object YieldSupplyModule { persistenceStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( moshi = moshi, - types = listTypes(), + types = listTypes(), defaultValue = emptyList(), ), produceFile = { context.dataStoreFile(fileName = "yield_markets_cache") }, 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 1ed58f2220..bf8bf7fa78 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 @@ -12,5 +12,7 @@ interface TangemPayStorage { suspend fun getOrderId(customerWalletAddress: String): String? - suspend fun clear(customerWalletAddress: String) + suspend fun clearOrderId(customerWalletAddress: String) + + suspend fun clearAll(customerWalletAddress: String) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt index b282890523..c1d86f9243 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt @@ -1,21 +1,21 @@ package com.tangem.datasource.local.yieldsupply import androidx.datastore.core.DataStore -import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull internal class DefaultYieldMarketsStore( - private val persistenceStore: DataStore>, + private val persistenceStore: DataStore>, ) : YieldMarketsStore { - override fun get(): Flow> = persistenceStore.data + override fun get(): Flow> = persistenceStore.data - override suspend fun getSyncOrNull(): List? { + override suspend fun getSyncOrNull(): List? { return persistenceStore.data.firstOrNull() } - override suspend fun store(items: List) { + override suspend fun store(items: List) { persistenceStore.updateData { _ -> items } } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt index c78c131d7a..d20f7aad04 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt @@ -1,13 +1,13 @@ package com.tangem.datasource.local.yieldsupply -import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import kotlinx.coroutines.flow.Flow interface YieldMarketsStore { - fun get(): Flow> + fun get(): Flow> - suspend fun getSyncOrNull(): List? + suspend fun getSyncOrNull(): List? - suspend fun store(items: List) + suspend fun store(items: List) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNulls.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNulls.kt new file mode 100644 index 0000000000..8361150150 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNulls.kt @@ -0,0 +1,5 @@ +package com.tangem.datasource.utils + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class SerializeNulls \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNullsFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNullsFactory.kt new file mode 100644 index 0000000000..ac77f01f7c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNullsFactory.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.utils + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.lang.reflect.Type + +/** + * Factory to serialize nulls in Moshi if the class is annotated with [SerializeNulls]. + * +[REDACTED_AUTHOR] + */ +internal object SerializeNullsFactory : JsonAdapter.Factory { + + override fun create(type: Type, annotations: MutableSet, moshi: Moshi): JsonAdapter<*>? { + val rawType = Types.getRawType(type) + if (!rawType.isAnnotationPresent(SerializeNulls::class.java)) { + return null + } + + val nextAdapter: JsonAdapter = moshi.nextAdapter(this, type, annotations) + + return nextAdapter.serializeNulls() + } +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/utils/SerializeNullsFactoryTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/utils/SerializeNullsFactoryTest.kt new file mode 100644 index 0000000000..9dafd03360 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/utils/SerializeNullsFactoryTest.kt @@ -0,0 +1,51 @@ +package com.tangem.datasource.utils + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +// --- DTO --- +@SerializeNulls +@JsonClass(generateAdapter = true) +data class UserWithNulls(val id: String?, val name: String?) + +@JsonClass(generateAdapter = true) +data class UserWithoutNulls(val id: String?, val name: String?) + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SerializeNullsFactoryTest { + + private val moshi = Moshi.Builder() + .add(SerializeNullsFactory) + .build() + + @Test + fun `should serialize nulls for annotated class`() { + val adapter = moshi.adapter(UserWithNulls::class.java) + + val json = adapter.toJson(UserWithNulls(id = null, name = "John")) + + assertThat(json).isEqualTo("""{"id":null,"name":"John"}""") + } + + @Test + fun `should skip nulls for non-annotated class`() { + val adapter = moshi.adapter(UserWithoutNulls::class.java) + + val json = adapter.toJson(UserWithoutNulls(id = null, name = "John")) + + assertThat(json).isEqualTo("""{"name":"John"}""") + } + + @Test + fun `should deserialize annotated class correctly`() { + val adapter = moshi.adapter(UserWithNulls::class.java) + + val json = """{"id":null,"name":"Jane"}""" + val result = adapter.fromJson(json) + + assertThat(result).isEqualTo(UserWithNulls(id = null, name = "Jane")) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt index f3bfb8028b..ebdceec152 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt @@ -40,7 +40,7 @@ fun SimpleSettingsRow( onItemsClick() } }, - ).testTag(BaseBottomSheetTestTags.ACTION_TITLE), + ).testTag(BaseBottomSheetTestTags.ACTION_BUTTON), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { @@ -48,7 +48,8 @@ fun SimpleSettingsRow( painter = painterResource(id = icon), contentDescription = null, modifier = Modifier - .padding(horizontal = if (redesign) TangemTheme.dimens.spacing12 else TangemTheme.dimens.spacing20), + .padding(horizontal = if (redesign) TangemTheme.dimens.spacing12 else TangemTheme.dimens.spacing20) + .testTag(BaseBottomSheetTestTags.ACTION_ICON), tint = rowColors.iconColor(enabled = enabled).value, ) Column( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt index 0e40554581..494129175b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt @@ -1,12 +1,54 @@ package com.tangem.core.ui.components import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import com.google.accompanist.systemuicontroller.SystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.res.LocalIsInDarkTheme +val LocalSystemBarsIconsController = staticCompositionLocalOf { + error("No SystemBarsIconsController provided") +} + +class SystemBarsIconsController(private val systemUiController: SystemUiController) { + private var count by mutableIntStateOf(0) + + fun setIcons(darkIcons: Boolean, isNavigationBarContrastEnforced: Boolean) { + if (count == 0) { + systemUiController.systemBarsDarkContentEnabled = darkIcons + systemUiController.isNavigationBarContrastEnforced = isNavigationBarContrastEnforced + } + count++ + } + + fun restoreIcons(isDarkTheme: Boolean) { + count-- + if (count == 0) { + systemUiController.systemBarsDarkContentEnabled = !isDarkTheme + systemUiController.isNavigationBarContrastEnforced = false + } + } +} + +@Composable +fun ProvideSystemBarsIconsController(content: @Composable () -> Unit) { + val systemUiController = rememberSystemUiController() + val controller = remember(systemUiController) { SystemBarsIconsController(systemUiController) } + + CompositionLocalProvider( + LocalSystemBarsIconsController provides controller, + content = content, + ) +} + /** * Provides the ability to set a scrim for 3-button navigation * @@ -43,19 +85,16 @@ fun NavigationBar3ButtonsScrim() { */ @Composable fun SystemBarsIconsDisposable(darkIcons: Boolean, isNavigationBarContrastEnforced: Boolean = false) { - val systemUiController = rememberSystemUiController() + val controller = LocalSystemBarsIconsController.current + val isDarkTheme = LocalIsInDarkTheme.current SideEffect { - systemUiController.systemBarsDarkContentEnabled = darkIcons - systemUiController.isNavigationBarContrastEnforced = isNavigationBarContrastEnforced + controller.setIcons(darkIcons, isNavigationBarContrastEnforced) } - val isDarkTheme = LocalIsInDarkTheme.current - DisposableEffect(isDarkTheme) { onDispose { - systemUiController.systemBarsDarkContentEnabled = !isDarkTheme - systemUiController.isNavigationBarContrastEnforced = false + controller.restoreIcons(isDarkTheme) } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt index 8aab6c9598..66cf54df35 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt @@ -51,7 +51,19 @@ fun BlockItem(model: BlockUM, modifier: Modifier = Modifier) { overflow = TextOverflow.Ellipsis, ) - model.label?.let { Label(it) } + when (val endContent = model.endContent) { + is BlockUM.EndContent.None -> Unit + is BlockUM.EndContent.Icon -> Icon( + painter = painterResource(id = endContent.resId), + contentDescription = null, + tint = when (endContent.accentType) { + BlockUM.AccentType.NONE -> TangemTheme.colors.text.primary1 + BlockUM.AccentType.ACCENT -> TangemTheme.colors.text.accent + BlockUM.AccentType.WARNING -> TangemTheme.colors.text.warning + }, + ) + is BlockUM.EndContent.Label -> Label(endContent.label) + } } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt index c950b5e4e7..4988f72eff 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt @@ -3,15 +3,29 @@ package com.tangem.core.ui.components.block.model import androidx.annotation.DrawableRes import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference +import javax.annotation.concurrent.Immutable data class BlockUM( val text: TextReference, @DrawableRes val iconRes: Int, val onClick: () -> Unit, val accentType: AccentType = AccentType.NONE, - val label: LabelUM? = null, + val endContent: EndContent = EndContent.None, ) { + @Immutable + sealed interface EndContent { + data object None : EndContent + data class Label( + val label: LabelUM, + ) : EndContent + + data class Icon( + val resId: Int, + val accentType: AccentType = AccentType.NONE, + ) : EndContent + } + enum class AccentType { NONE, ACCENT, WARNING, } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt index d89d6710c1..4e74046e6c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt @@ -27,15 +27,20 @@ private const val DISABLED_ICON_ALPHA = 0.4f * [Figma Component](https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=2737-2800&t=ewlXfWwbDnRhjw4B-4) * */ @Composable -fun BlockchainRow(model: BlockchainRowUM, modifier: Modifier = Modifier, action: @Composable BoxScope.() -> Unit) { +fun BlockchainRow( + model: BlockchainRowUM, + modifier: Modifier = Modifier, + itemPadding: PaddingValues = PaddingValues( + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing8, + ), + action: @Composable BoxScope.() -> Unit, +) { RowContentContainer( modifier = modifier .heightIn(min = TangemTheme.dimens.size52) - .padding( - top = TangemTheme.dimens.spacing8, - bottom = TangemTheme.dimens.spacing8, - start = TangemTheme.dimens.spacing8, - ), + .padding(itemPadding), icon = { RowIcon( resId = model.iconResId, @@ -58,7 +63,7 @@ fun BlockchainRow(model: BlockchainRowUM, modifier: Modifier = Modifier, action: } @Composable -private fun RowIcon( +fun RowIcon( @DrawableRes resId: Int, isColored: Boolean, showAccentBadge: Boolean, 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 ff3b869c1f..fa775ca36c 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 @@ -60,10 +60,10 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M @Composable fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { if (state.isExpanded) { - ExpandedPortfolioHeader(state.state, modifier) + ExpandedPortfolioHeader(state = state.tokenItemUM, isCollapsable = state.isCollapsable, modifier = modifier) } else { TokenItem( - state = state.state, + state = state.tokenItemUM, isBalanceHidden = isBalanceHidden, modifier = modifier, ) @@ -83,7 +83,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B } @Composable -private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier = Modifier) { +private fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -130,11 +130,13 @@ private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier = ) } - Icon( - modifier = Modifier.size(TangemTheme.dimens.size16), - painter = painterResource(id = R.drawable.ic_minimize_24), - tint = TangemTheme.colors.icon.inactive, - contentDescription = null, - ) + if (isCollapsable) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size16), + painter = painterResource(id = R.drawable.ic_minimize_24), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + ) + } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt index 24da6e9d28..7797159dc1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList /** Tokens list item state */ @Immutable @@ -41,11 +42,12 @@ sealed interface TokensListItemUM { } data class Portfolio( - val state: TokenItemState, + val tokenItemUM: TokenItemState, val isExpanded: Boolean, - val tokens: List, + val isCollapsable: Boolean, + val tokens: ImmutableList, ) : TokensListItemUM { - override val id: String = state.id + override val id: String = tokenItemUM.id } data class Text(override val id: Any, val text: TextReference) : TokensListItemUM diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableListContentComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableListContentComponent.kt new file mode 100644 index 0000000000..75f52c5be4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableListContentComponent.kt @@ -0,0 +1,26 @@ +package com.tangem.core.ui.decompose + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@Stable +interface ComposableListContentComponent { + + val uiState: StateFlow + + fun LazyListScope.content(uiState: T, modifier: Modifier) + + companion object { + val EMPTY = EmptyComposableListContentComponent + } +} + +object EmptyComposableListContentComponent : ComposableListContentComponent { + override val uiState: StateFlow = MutableStateFlow(Unit) + + override fun LazyListScope.content(uiState: Unit, modifier: Modifier) { /* no-op */ + } +} \ No newline at end of file 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 e106f177ab..329a2d7744 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 @@ -90,12 +90,14 @@ fun getActiveIconRes(blockchainId: String): Int { "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 "sonic", "sonic/test" -> R.drawable.img_sonic_22 "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration + "scroll", "scroll/test" -> R.drawable.img_scroll_22 "zklink", "zklink/test" -> R.drawable.img_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 "pepecoin", "pepecoin/test" -> R.drawable.img_pepecoin_22 "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 "quai", "quai/test" -> R.drawable.img_quai_22 + "linea", "linea/test" -> R.drawable.img_linea_22 + "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 else -> R.drawable.ic_alert_24 } } @@ -184,12 +186,14 @@ fun getActiveIconResByCoinId(coinId: String): Int { "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 "sonic", "sonic/test" -> R.drawable.img_sonic_22 "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration + "scroll", "scroll/test" -> R.drawable.img_scroll_22 "zklink", "zklink/test" -> R.drawable.img_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 "pepecoin-network", "pepecoin-network/test" -> R.drawable.img_pepecoin_22 "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 "quai", "quai/test" -> R.drawable.img_quai_22 + "linea", "linea/test" -> R.drawable.img_linea_22 + "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 else -> R.drawable.ic_alert_24 } } @@ -281,12 +285,14 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "bitrock", "bitrock/test" -> R.drawable.ic_bitrock_22 "sonic", "sonic/test" -> R.drawable.ic_sonic_22 "apechain", "apechain/test" -> R.drawable.ic_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration + "scroll", "scroll/test" -> R.drawable.ic_scroll_22 "zklink", "zklink/test" -> R.drawable.ic_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.ic_vanar_22 "pepecoin", "pepecoin/test" -> R.drawable.ic_pepecoin_22 "hyperliquid", "hyperliquid/test" -> R.drawable.ic_hyperliquid_22 "quai", "quai/test" -> R.drawable.ic_quai_22 + "linea", "linea/test" -> R.drawable.ic_linea_22 + "arbitrum-nova" -> R.drawable.ic_arbitrum_nova_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/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 85baa41281..01765f772d 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 @@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.UiDependencies +import com.tangem.core.ui.components.LocalSystemBarsIconsController +import com.tangem.core.ui.components.SystemBarsIconsController import com.tangem.core.ui.components.TangemShimmer import com.tangem.core.ui.components.text.BladeAnimation import com.tangem.core.ui.components.text.rememberBladeAnimation @@ -65,6 +67,8 @@ fun TangemTheme( val themeColors = if (isDark) darkThemeColors() else lightThemeColors() val rememberedColors = remember { themeColors } .also { it.update(themeColors) } + val systemUiController = rememberSystemUiController() + val systemBarsIconsController = remember(systemUiController) { SystemBarsIconsController(systemUiController) } val shapes = remember { TangemShapes(dimens) } @@ -103,6 +107,7 @@ fun TangemTheme( LocalEventMessageHandler provides eventMessageHandler, LocalWindowSize provides windowSize, LocalBladeAnimation provides rememberBladeAnimation(), + LocalSystemBarsIconsController provides systemBarsIconsController, ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, @@ -119,6 +124,14 @@ fun TangemTheme( } } +@Composable +fun ForceDarkTheme(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalTangemColors provides darkThemeColors(), + content = content, + ) +} + object TangemTheme { val colors: TangemColors @Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt index d2d4a850de..ccf4a1033e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt @@ -1,7 +1,8 @@ package com.tangem.core.ui.test object BaseBottomSheetTestTags { - const val ACTION_TITLE = "BASE_BOTTOM_SHEET_ACTION_TITLE" + const val ACTION_BUTTON = "BASE_BOTTOM_SHEET_ACTION_BUTTON" + const val ACTION_ICON = "BASE_BOTTOM_SHEET_ACTION_ICON" const val TITLE = "BASE_BOTTOM_SHEET_TITLE" const val SUBTITLE = "BASE_BOTTOM_SHEET_SUBTITLE" const val CLOSE_BUTTON = "BASE_BOTTOM_SHEET_CLOSE_BUTTON" 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 bc025954ac..fe8785cc8c 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 @@ -4,13 +4,11 @@ object SendScreenTestTags { const val SCREEN_CONTAINER = "SEND_SCREEN_CONTAINER" const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE" - const val AMOUNT_CONTAINER_TEXT = "SEND_SCREEN_AMOUNT_CONTAINER_TEXT" const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD" + const val TOKEN_NAME = "SEND_SCREEN_TOKEN_NAME" + const val PRIMARY_AMOUNT = "SEND_SCREEN_PRIMARY_AMOUNT" const val SECONDARY_AMOUNT = "SEND_SCREEN_SECONDARY_AMOUNT" - const val CURRENCY_BUTTON = "SEND_SCREEN_CURRENCY_BUTTON" - const val FIAT_ICON = "SEND_SCREEN_FIAT_ICON" - const val CURRENCY_ICON = "SEND_SCREEN_CURRENCY_ICON" - const val MAX_BUTTON = "END_SCREEN_MAX_BUTTON" + const val MAX_BUTTON = "SEND_SCREEN_MAX_BUTTON" const val PREVIOUS_BUTTON = "SEND_SCREEN_PREVIOUS_BUTTON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt index cd6cf947d1..c64a02bb85 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt @@ -9,6 +9,6 @@ object SwapTokenScreenTestTags { const val PROVIDERS_BLOCK = "SWAP_TOKEN_SCREEN_PROVIDERS_BLOCK" const val SWAP_BUTTON = "SWAP_TOKEN_SCREEN_SWAP_BUTTON" const val TOKEN = "SWAP_TOKEN_SCREEN_TOKEN" - const val TOKEN_NAME = "SWAP_TOKEN_SCREEN_TOKEN_NAME" + const val TOKEN_SYMBOL = "SWAP_TOKEN_SCREEN_TOKEN_SYMBOL" const val TOKEN_ICON = "SWAP_TOKEN_SCREEN_TOKEN_ICON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveQrCodeBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveQrCodeBottomSheetTestTags.kt new file mode 100644 index 0000000000..0d9ae631fd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveQrCodeBottomSheetTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object TokenReceiveQrCodeBottomSheetTestTags { + const val TITLE = "TOKEN_RECEIVE_QR_CODE_BOTTOM_SHEET_TITLE" + const val QR_CODE = "TOKEN_RECEIVE_QR_CODE_BOTTOM_SHEET_QR_CODE" + const val ADDRESS = "TOKEN_RECEIVE_QR_CODE_BOTTOM_SHEET_ADDRESS" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveWarningBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveWarningBottomSheetTestTags.kt new file mode 100644 index 0000000000..e644401122 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveWarningBottomSheetTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object TokenReceiveWarningBottomSheetTestTags { + const val BOTTOM_SHEET = "TOKEN_RECEIVE_WARNING_BOTTOM_SHEET" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt index 8280fc5021..53400f7588 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt @@ -7,4 +7,5 @@ object WalletConnectScreenTestTags { const val APP_NAME = "WALLET_CONNECT_SCREEN_APP_NAME" const val APPROVE_ICON = "WALLET_CONNECT_SCREEN_APPROVE_ICON" const val APP_URL = "WALLET_CONNECT_SCREEN_APP_URL" + const val WALLET_CONNECT_IMAGE = "WALLET_CONNECT_SCREEN_WALLET_CONNECT_IMAGE" } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable-hdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-hdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000..471779539c Binary files /dev/null and b/core/ui/src/main/res/drawable-hdpi/img_hardware_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-hdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-hdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000..74316b4df5 Binary files /dev/null and b/core/ui/src/main/res/drawable-hdpi/img_mobile_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-mdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-mdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000..20fe7241fc Binary files /dev/null and b/core/ui/src/main/res/drawable-mdpi/img_hardware_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-mdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-mdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000..7c2cbfe1fe Binary files /dev/null and b/core/ui/src/main/res/drawable-mdpi/img_mobile_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-xhdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-xhdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000..7aadd5dc10 Binary files /dev/null and b/core/ui/src/main/res/drawable-xhdpi/img_hardware_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-xhdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-xhdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000..b42de3dea2 Binary files /dev/null and b/core/ui/src/main/res/drawable-xhdpi/img_mobile_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-xxhdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-xxhdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000..c9e3b37299 Binary files /dev/null and b/core/ui/src/main/res/drawable-xxhdpi/img_hardware_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-xxhdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-xxhdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000..d0dc780332 Binary files /dev/null and b/core/ui/src/main/res/drawable-xxhdpi/img_mobile_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-xxxhdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-xxxhdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000..2626432e01 Binary files /dev/null and b/core/ui/src/main/res/drawable-xxxhdpi/img_hardware_wallet.webp differ diff --git a/core/ui/src/main/res/drawable-xxxhdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-xxxhdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000..4b9a5bd3c6 Binary files /dev/null and b/core/ui/src/main/res/drawable-xxxhdpi/img_mobile_wallet.webp differ diff --git a/core/ui/src/main/res/drawable/ic_add_wallet_16.xml b/core/ui/src/main/res/drawable/ic_add_wallet_16.xml new file mode 100644 index 0000000000..48e62db467 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_add_wallet_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_arbitrum_nova_22.xml b/core/ui/src/main/res/drawable/ic_arbitrum_nova_22.xml new file mode 100644 index 0000000000..6607ba52a3 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_arbitrum_nova_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_chevron_right_18x24.xml b/core/ui/src/main/res/drawable/ic_chevron_right_18x24.xml new file mode 100644 index 0000000000..b3e1a0461d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_chevron_right_18x24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_flash_16.xml b/core/ui/src/main/res/drawable/ic_flash_16.xml new file mode 100644 index 0000000000..ae87edfc41 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_flash_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_import_seed_16.xml b/core/ui/src/main/res/drawable/ic_import_seed_16.xml new file mode 100644 index 0000000000..6e96d53e29 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_import_seed_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_linea_22.xml b/core/ui/src/main/res/drawable/ic_linea_22.xml new file mode 100644 index 0000000000..32b14b57c5 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_linea_22.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_mobile_wallet_16.xml b/core/ui/src/main/res/drawable/ic_mobile_wallet_16.xml new file mode 100644 index 0000000000..e37da72eac --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_mobile_wallet_16.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_scroll_22.xml b/core/ui/src/main/res/drawable/ic_scroll_22.xml new file mode 100644 index 0000000000..802b10c5d4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_scroll_22.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_shield_check_16.xml b/core/ui/src/main/res/drawable/ic_shield_check_16.xml new file mode 100644 index 0000000000..3b36f1acf0 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_shield_check_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_sparkles_16.xml b/core/ui/src/main/res/drawable/ic_sparkles_16.xml new file mode 100644 index 0000000000..8add70e924 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_sparkles_16.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_stack_fill_new_16.xml b/core/ui/src/main/res/drawable/ic_stack_fill_new_16.xml new file mode 100644 index 0000000000..e5518cf3fd --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_stack_fill_new_16.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/img_arbitrum_nova_22.xml b/core/ui/src/main/res/drawable/img_arbitrum_nova_22.xml new file mode 100644 index 0000000000..2ef6dbac42 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_arbitrum_nova_22.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_linea_22.xml b/core/ui/src/main/res/drawable/img_linea_22.xml new file mode 100644 index 0000000000..5c2542faaa --- /dev/null +++ b/core/ui/src/main/res/drawable/img_linea_22.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_scroll_22.xml b/core/ui/src/main/res/drawable/img_scroll_22.xml new file mode 100644 index 0000000000..1772fd5f5e --- /dev/null +++ b/core/ui/src/main/res/drawable/img_scroll_22.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index d5c297866a..58b5628a0d 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { // region Project - Domain api(projects.domain.account) api(projects.domain.card) + api(projects.domain.common) api(projects.domain.models) // endregion 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 d4c993f59f..b2744a7452 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 @@ -28,7 +28,7 @@ internal class AccountListConverter @AssistedInject constructor( override fun convert(value: GetWalletAccountsResponse): AccountList { return AccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(), totalAccounts = value.wallet.totalAccounts, sortType = TokensSortTypeConverter.convert(value.wallet.sort), diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt index 07c76c7c24..8f26a20de5 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt @@ -1,7 +1,6 @@ package com.tangem.data.account.converter import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse -import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.account.models.AccountList import com.tangem.domain.models.account.Account import com.tangem.utils.converter.Converter @@ -21,8 +20,8 @@ internal object SaveWalletAccountsResponseConverter : Converter) { - push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts)) + override suspend fun push( + userWalletId: UserWalletId, + accounts: List, + ): GetWalletAccountsResponse? { + return push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts)) } - override suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) { - safeApiCall( + override suspend fun push( + userWalletId: UserWalletId, + body: SaveWalletAccountsResponse, + ): GetWalletAccountsResponse? { + return safeApiCall( call = { var eTag = getETag(userWalletId) @@ -86,11 +94,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( tangemTechApi.saveWalletAccounts( walletId = userWalletId.stringValue, eTag = eTag, - body = body.copy( - accounts = body.accounts.map { - it.copy(tokens = null, totalTokens = null, totalNetworks = null) - }, - ), + body = body, ) } @@ -102,6 +106,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) { throw error } + + null }, ) } @@ -135,12 +141,28 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( pushWalletAccounts = ::push, storeWalletAccounts = ::store, ) - - null }, ) } + private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { + val response = defaultWalletAccountsResponseFactory.create( + userWalletId = userWalletId, + userTokensResponse = UserTokensResponse( + group = accountsResponse.wallet.group, + sort = accountsResponse.wallet.sort, + tokens = accountsResponse.unassignedTokens, + ), + ) + + userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) + val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) + + if (syncedResponse != null) { + store(userWalletId = userWalletId, response = syncedResponse) + } + } + private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId) 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 2acc302f24..0d9ea8c585 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,10 +1,8 @@ package com.tangem.data.account.fetcher -import com.tangem.data.account.converter.CryptoPortfolioConverter -import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.toUserTokensResponse -import com.tangem.data.common.currency.CardCryptoCurrencyFactory -import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code @@ -13,10 +11,6 @@ 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.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import timber.log.Timber import javax.inject.Inject @@ -24,12 +18,9 @@ import javax.inject.Inject /** * Handles errors that occur during the fetching of wallet accounts * - * @property userTokensSaver saves user tokens to the storage - * @property userWalletsStore provides access to user wallet data - * @property userTokensResponseStore provides access to user token responses. - * @property cryptoPortfolioCF factory for converting crypto portfolios - * @property userTokensResponseFactory factory for creating user token responses - * @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets + * @property userTokensSaver saves user tokens to the storage + * @property userTokensResponseStore provides access to user token responses. + * @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse] * * @see DefaultWalletAccountsFetcher * @@ -37,11 +28,8 @@ import javax.inject.Inject */ internal class FetchWalletAccountsErrorHandler @Inject constructor( private val userTokensSaver: UserTokensSaver, - private val userWalletsStore: UserWalletsStore, private val userTokensResponseStore: UserTokensResponseStore, - private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, - private val userTokensResponseFactory: UserTokensResponseFactory, - private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, + private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, ) { /** @@ -59,72 +47,47 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( error: ApiResponseError, userWalletId: UserWalletId, savedAccountsResponse: GetWalletAccountsResponse?, - pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List) -> Unit, - storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit, - ) { + pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse?, + storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit, + ): 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 + return savedAccountsResponse } - val (accountDTOs, userTokensResponse) = if (savedAccountsResponse == null) { - val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) - - createDefaultAccountDTOs(userWallet) to getFromLegacyStore(userWalletId).orDefault(userWallet) - } else { - savedAccountsResponse.accounts to savedAccountsResponse.toUserTokensResponse() - } + var response = savedAccountsResponse ?: createDefaultResponse(userWalletId) + val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse() val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) if (isNotFoundError) { - pushWalletAccounts(userWalletId, accountDTOs) userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) + val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs) + + if (updatedResponse != null) { + response = updatedResponse + } } - val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse) storeWalletAccounts(userWalletId, response) + + return response } - private fun createDefaultAccountDTOs(userWallet: UserWallet): List { - val accounts = AccountList.empty(userWallet).accounts - .filterIsInstance() - - val converter = cryptoPortfolioCF.create(userWallet = userWallet) - - return converter.convertListBack(input = accounts) + private suspend fun createDefaultResponse(userWalletId: UserWalletId): GetWalletAccountsResponse { + return defaultWalletAccountsResponseFactory.create( + userWalletId = userWalletId, + userTokensResponse = getFromLegacyStore(userWalletId), + ) } private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? { return userTokensResponseStore.getSyncOrNull(userWalletId) + ?.let { + it.copy( + tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = it.tokens), + ) + } .also { userTokensResponseStore.clear(userWalletId) } } - - private fun UserTokensResponse?.orDefault(userWallet: UserWallet): UserTokensResponse { - if (this != null) return this - - return userTokensResponseFactory.createUserTokensResponse( - currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet = userWallet), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - } - - private fun GetWalletAccountsResponse?.orDefault( - userWalletId: UserWalletId, - accountDTOs: List, - userTokensResponse: UserTokensResponse, - ): GetWalletAccountsResponse { - if (this != null) return this - - return GetWalletAccountsResponse( - wallet = GetWalletAccountsResponse.Wallet( - group = userTokensResponse.group, - sort = userTokensResponse.sort, - totalAccounts = accountDTOs.size, - ), - accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens), - unassignedTokens = emptyList(), - ) - } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt index da4e0e7853..40e4fd5f47 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt @@ -5,6 +5,7 @@ import arrow.core.some import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.models.wallet.UserWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -35,10 +36,11 @@ internal class DefaultMultiAccountListProducer @AssistedInject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow> { return userWalletsStore.userWallets + .map { it.map(UserWallet::walletId) } .distinctUntilChanged() - .flatMapLatest { userWallets -> + .flatMapLatest { ids -> combine( - flows = userWallets.map(walletAccountListFlowFactory::create), + flows = ids.map(walletAccountListFlowFactory::create), transform = ::listOf, ) } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt index 43d0745ea2..6288d9036f 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -2,7 +2,6 @@ package com.tangem.data.account.producer import arrow.core.Option import arrow.core.none -import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -11,16 +10,13 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapNotNull /** * Default implementation of [SingleAccountListProducer]. * Produces a list of [AccountList] for a specific user wallet. * * @property params params containing the user wallet ID - * @property userWalletsStore store that provides user wallets * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet * @property dispatchers coroutine dispatchers provider * @@ -28,7 +24,6 @@ import kotlinx.coroutines.flow.mapNotNull */ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @Assisted val params: SingleAccountListProducer.Params, - private val userWalletsStore: UserWalletsStore, private val walletAccountListFlowFactory: WalletAccountListFlowFactory, private val dispatchers: CoroutineDispatcherProvider, ) : SingleAccountListProducer { @@ -37,11 +32,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow { - return userWalletsStore.userWallets - .mapNotNull { userWallets -> - userWallets.firstOrNull { it.walletId == params.userWalletId } - } - .flatMapLatest(walletAccountListFlowFactory::create) + return walletAccountListFlowFactory.create(userWalletId = params.userWalletId) .flowOn(dispatchers.default) } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt index 765d6afcc1..44aa8713fc 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -4,9 +4,11 @@ import com.tangem.data.account.converter.AccountListConverter import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.card.common.util.cardTypesResolver 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.models.wallet.requireColdWallet import kotlinx.coroutines.flow.* @@ -22,12 +24,15 @@ import javax.inject.Inject [REDACTED_AUTHOR] */ internal class WalletAccountListFlowFactory @Inject constructor( + private val userWalletsStore: UserWalletsStore, private val accountsResponseStoreFactory: AccountsResponseStoreFactory, private val accountListConverterFactory: AccountListConverter.Factory, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, ) { - fun create(userWallet: UserWallet): Flow { + fun create(userWalletId: UserWalletId): Flow { + val userWallet = userWalletsStore.getSyncStrict(userWalletId) + return if (userWallet.isMultiCurrency) { createForMultiWallet(userWallet) } else { @@ -53,6 +58,6 @@ internal class WalletAccountListFlowFactory @Inject constructor( setOf(cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet)) } - return AccountList.empty(userWallet = userWallet, cryptoCurrencies = currencies) + return AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = currencies) } } \ 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 a9ceb3026e..6dc1771d27 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 @@ -24,6 +24,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.replaceBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -101,12 +102,33 @@ internal class DefaultAccountsCRUDRepository( } override suspend fun saveAccounts(accountList: AccountList) { - val userWalletId = accountList.userWallet.walletId + val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = accountList.userWalletId) - val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet) - val accountsResponse = converter.convert(value = accountList) + val accountDTOs = converter.convertListBack( + input = accountList.accounts.filterIsInstance(), + ) - walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse) + val syncedResponse = walletAccountsSaver.push(userWalletId = accountList.userWalletId, accounts = accountDTOs) + if (syncedResponse != null) { + walletAccountsSaver.store(userWalletId = accountList.userWalletId, response = syncedResponse) + } + } + + override suspend fun saveAccount(account: Account.CryptoPortfolio) { + val store = getAccountsResponseStore(userWalletId = account.userWalletId) + + val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = account.userWalletId) + val newAccountDTO = converter.convertBack(value = account) + + store.updateData { response -> + response ?: return@updateData response + + response.copy( + accounts = response.accounts.toMutableList().apply { + replaceBy(newAccountDTO) { it.id == newAccountDTO.id } + }, + ) + } } override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option = option { 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 new file mode 100644 index 0000000000..8dad68c1de --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt @@ -0,0 +1,68 @@ +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.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject + +/** + * Factory to create default [GetWalletAccountsResponse]. + * + * @property userWalletsListRepository repository to get user wallet information + * @property cryptoPortfolioCF converter factory to convert crypto portfolio accounts + * @property userTokensResponseFactory factory to create [UserTokensResponse] + * @property cardCryptoCurrencyFactory factory to get default coins for multi-currency wallet + * +[REDACTED_AUTHOR] + */ +internal class DefaultWalletAccountsResponseFactory @Inject constructor( + private val userWalletsListRepository: UserWalletsListRepository, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userTokensResponseFactory: UserTokensResponseFactory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + suspend fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse { + val userWallet = userWalletsListRepository.userWalletsSync().firstOrNull { it.walletId == userWalletId } + + val accountDTOs = userWallet?.let(::createDefaultAccountDTOs).orEmpty() + val response = userTokensResponse.orDefault(userWallet = userWallet) + + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = response.group, + sort = response.sort, + totalAccounts = accountDTOs.size, + ), + accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = response.tokens), + unassignedTokens = emptyList(), + ) + } + + private fun createDefaultAccountDTOs(userWallet: UserWallet): List { + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + + val converter = cryptoPortfolioCF.create(userWallet = userWallet) + + return converter.convertListBack(input = accounts) + } + + 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, + ) + } +} \ 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 880b20520e..12ac6dda89 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 @@ -8,7 +8,6 @@ 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.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId internal fun createWalletAccountDTO( @@ -72,13 +71,13 @@ internal fun createGetWalletAccountsResponse( } internal fun createAccountList( - userWallet: UserWallet, + userWalletId: UserWalletId, sortType: TokensSortType = TokensSortType.BALANCE, groupType: TokensGroupType = TokensGroupType.NETWORK, ): AccountList { return AccountList( - userWallet = userWallet, - accounts = setOf(createCryptoPortfolio(userWallet.walletId)), + userWalletId = userWalletId, + accounts = setOf(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 8b32adb5e5..1a7ae063f0 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 @@ -96,7 +96,7 @@ class AccountListConverterTest { ), expected = Result.success( createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.BALANCE, groupType = TokensGroupType.NETWORK, ), @@ -110,7 +110,7 @@ class AccountListConverterTest { ), expected = Result.success( createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.NONE, groupType = TokensGroupType.NONE, ), @@ -124,7 +124,7 @@ class AccountListConverterTest { ), expected = Result.success( createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.NONE, groupType = TokensGroupType.NONE, ), 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 766544b58a..2b09ada07f 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 @@ -46,7 +46,7 @@ class GetWalletAccountsResponseConverterTest { @Test fun `cryptoPortfolioConverter throws exception`() { // Arrange - val domain = createAccountList(userWallet = userWallet) + val domain = createAccountList(userWalletId = userWallet.walletId) val exception = IllegalStateException("Test exception") every { cryptoPortfolioConverter.convertBack(any()) } throws exception @@ -92,7 +92,7 @@ class GetWalletAccountsResponseConverterTest { return listOf( ConvertModel( value = createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.BALANCE, groupType = TokensGroupType.NETWORK, ), @@ -106,7 +106,7 @@ class GetWalletAccountsResponseConverterTest { ), ConvertModel( value = createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.NONE, groupType = TokensGroupType.NONE, ), 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 6d5ebee814..35e95170e1 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,14 +2,10 @@ package com.tangem.data.account.converter import com.google.common.truth.Truth import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse -import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO 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.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import io.mockk.every -import io.mockk.mockk import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -19,13 +15,11 @@ class SaveWalletAccountsResponseConverterTest { @Test fun convert() { // Arrange - val userWallet = mockk { - every { this@mockk.walletId } returns UserWalletId("011") - } + val userWalletId = UserWalletId("011") val accountList = AccountList( - userWallet = userWallet, - accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)), + userWalletId = userWalletId, + accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), totalAccounts = 1, ) .getOrNull()!! @@ -36,7 +30,7 @@ class SaveWalletAccountsResponseConverterTest { // Assert val expected = SaveWalletAccountsResponse( accounts = listOf( - WalletAccountDTO( + SaveWalletAccountsResponse.AccountDTO( id = accountList.mainAccount.accountId.value, name = (accountList.mainAccount.accountName as? AccountName.Custom)?.value, derivationIndex = accountList.mainAccount.derivationIndex.value, 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 ac012e76fb..55be8a64db 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 @@ -5,6 +5,7 @@ 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.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponse @@ -36,6 +37,7 @@ class DefaultWalletAccountsFetcherTest { private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) + private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true) private val fetcher: DefaultWalletAccountsFetcher = DefaultWalletAccountsFetcher( @@ -43,6 +45,7 @@ class DefaultWalletAccountsFetcherTest { accountsResponseStoreFactory = accountsResponseStoreFactory, userTokensSaver = userTokensSaver, fetchWalletAccountsErrorHandler = fetchWalletAccountsErrorHandler, + defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, eTagsStore = eTagsStore, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -114,7 +117,7 @@ class DefaultWalletAccountsFetcherTest { eTag = eTag, body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), ) - } returns ApiResponse.Success(data = Unit) + } returns ApiResponse.Success(data = updatedAccountsResponse) // Act fetcher.fetch(userWalletId) @@ -205,6 +208,16 @@ class DefaultWalletAccountsFetcherTest { tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) } returns apiError as ApiResponse + coEvery { + fetchWalletAccountsErrorHandler.handle( + error = apiError.cause, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } returns savedAccountsResponse + // Act fetcher.fetch(userWalletId) @@ -260,26 +273,26 @@ class DefaultWalletAccountsFetcherTest { @Test fun `push should call saveWalletAccounts with correct params`() = runTest { // Arrange - val accounts = listOf(createWalletAccountDTO(userWalletId = userWalletId, tokens = null)) - val response = SaveWalletAccountsResponse(accounts) + val getResponse = createGetWalletAccountsResponse(userWalletId, tokens = null) + val saveResponse = SaveWalletAccountsResponse(getResponse.accounts) coEvery { tangemTechApi.saveWalletAccounts( walletId = userWalletId.stringValue, eTag = eTag, - body = response, + body = saveResponse, ) - } returns ApiResponse.Success(data = Unit) + } returns ApiResponse.Success(data = getResponse) // Act - fetcher.push(userWalletId, response) + fetcher.push(userWalletId, saveResponse) // Assert coVerify { tangemTechApi.saveWalletAccounts( walletId = userWalletId.stringValue, eTag = eTag, - body = response, + body = saveResponse, ) } } @@ -303,7 +316,7 @@ class DefaultWalletAccountsFetcherTest { eTag = eTag, body = response, ) - } returns saveApiResponse as ApiResponse + } returns saveApiResponse as ApiResponse // Act val actual = runCatching { fetcher.push(userWalletId, response) }.exceptionOrNull()!! @@ -313,42 +326,6 @@ class DefaultWalletAccountsFetcherTest { } } - @Nested - @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class PushAndStore { - - @Test - fun `pushAndStore should call push and store with correct params`() = runTest { - // Arrange - val accounts = listOf(createWalletAccountDTO(userWalletId = userWalletId, tokens = null)) - val response = createGetWalletAccountsResponse(userWalletId).copy(accounts = accounts) - - coEvery { - tangemTechApi.saveWalletAccounts( - walletId = userWalletId.stringValue, - eTag = eTag, - body = SaveWalletAccountsResponse(accounts = response.accounts), - ) - } returns ApiResponse.Success(data = Unit) - - coEvery { accountsResponseStore.updateData(any()) } returns mockk() - - // Act - fetcher.pushAndStore(userWalletId, response) - - // Assert - coVerifyOrder { - tangemTechApi.saveWalletAccounts( - walletId = userWalletId.stringValue, - eTag = eTag, - body = SaveWalletAccountsResponse(accounts = response.accounts), - ) - accountsResponseStoreFactory.create(userWalletId) - accountsResponseStore.updateData(any()) - } - } - } - private fun createToken( networkId: String = "ethereum", derivationPath: String = "m/44'/60'/0'/0/0", 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 422c9a7b8a..6500556de8 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 @@ -1,9 +1,9 @@ package com.tangem.data.account.fetcher -import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.converter.createWalletAccountDTO +import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.toUserTokensResponse -import com.tangem.data.common.currency.CardCryptoCurrencyFactory -import com.tangem.data.common.currency.UserTokensResponseFactory import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code @@ -11,12 +11,11 @@ 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.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import io.mockk.* +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -29,35 +28,25 @@ import org.junit.jupiter.api.TestInstance class FetchWalletAccountsErrorHandlerTest { private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) - private val userWalletsStore: UserWalletsStore = mockk() private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) - private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk() - private val cryptoPortfolioConverter = mockk() - private val userTokensResponseFactory: UserTokensResponseFactory = mockk() - private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val handler = FetchWalletAccountsErrorHandler( userTokensSaver = userTokensSaver, - userWalletsStore = userWalletsStore, userTokensResponseStore = userTokensResponseStore, - cryptoPortfolioCF = cryptoPortfolioCF, - userTokensResponseFactory = userTokensResponseFactory, - cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, ) - private val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - } + private val pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse = + mockk(relaxed = true) + private val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) @BeforeEach fun setupEach() { clearMocks( userTokensSaver, - userWalletsStore, userTokensResponseStore, - cryptoPortfolioCF, - cryptoPortfolioConverter, - cardCryptoCurrencyFactory, + defaultWalletAccountsResponseFactory, ) } @@ -70,9 +59,6 @@ class FetchWalletAccountsErrorHandlerTest { errorBody = null, ) - val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk() - val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk() - // Act handler.handle( error = error, @@ -84,12 +70,8 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify(inverse = true) { - userWalletsStore.getSyncStrict(key = any()) userTokensResponseStore.getSyncOrNull(userWalletId = any()) - userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) - cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) - cryptoPortfolioCF.create(any()) - cryptoPortfolioConverter.convertListBack(any()) + defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any()) pushWalletAccounts(any(), any()) userTokensSaver.push(userWalletId = any(), response = any()) storeWalletAccounts(any(), any()) @@ -105,29 +87,11 @@ class FetchWalletAccountsErrorHandlerTest { errorBody = null, ) - val accountDTO = WalletAccountDTO( - id = "nibh", - name = "Michael Dotson", - derivationIndex = 7135, - icon = "consectetuer", - iconColor = "ferri", - tokens = listOf(), - totalTokens = 7738, - totalNetworks = 3348, - ) + val accountDTO = createWalletAccountDTO(userWalletId) - val savedAccountsResponse = GetWalletAccountsResponse( - wallet = GetWalletAccountsResponse.Wallet( - group = UserTokensResponse.GroupType.NONE, - sort = UserTokensResponse.SortType.MANUAL, - totalAccounts = 1, - ), - accounts = listOf(accountDTO), - unassignedTokens = emptyList(), - ) + val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId) - val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) - val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse // Act handler.handle( @@ -140,18 +104,14 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { - pushWalletAccounts(userWalletId, listOf(accountDTO)) userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + pushWalletAccounts(userWalletId, listOf(accountDTO)) storeWalletAccounts(userWalletId, savedAccountsResponse) } coVerify(inverse = true) { - userWalletsStore.getSyncStrict(key = any()) userTokensResponseStore.getSyncOrNull(userWalletId = any()) - userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) - cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) - cryptoPortfolioCF.create(any()) - cryptoPortfolioConverter.convertListBack(any()) + defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any()) } } @@ -160,9 +120,6 @@ class FetchWalletAccountsErrorHandlerTest { // Arrange val error = ApiResponseError.TimeoutException() - val accounts = AccountList.empty(userWallet).accounts - .filterIsInstance() - val accountDTO = WalletAccountDTO( id = "nibh", name = "Michael Dotson", @@ -186,21 +143,10 @@ class FetchWalletAccountsErrorHandlerTest { val userTokensResponse = savedAccountsResponse.toUserTokensResponse() - every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet - every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter - every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO) - coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null - every { - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - } returns userTokensResponse - every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() - - val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) - val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns userTokensResponse + coEvery { + defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse) + } returns savedAccountsResponse // Act handler.handle( @@ -213,16 +159,8 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { - userWalletsStore.getSyncStrict(userWalletId) - cryptoPortfolioCF.create(userWallet) - cryptoPortfolioConverter.convertListBack(accounts) userTokensResponseStore.getSyncOrNull(userWalletId) - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) + defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse) storeWalletAccounts(userWalletId, 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 3ca26b0198..05cb95cca1 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 @@ -51,8 +51,8 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) - every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + val accountList = AccountList.empty(userWalletId) + every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) // Act val actual = producer.produce().let(::getEmittedValues) @@ -63,7 +63,7 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -73,11 +73,11 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) - val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val accountList = AccountList.empty(userWalletId) + val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -95,9 +95,9 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -107,10 +107,10 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -128,9 +128,9 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -141,7 +141,7 @@ class DefaultMultiAccountListProducerTest { every { userWalletsStore.userWallets } returns userWalletsFlow val exception = RuntimeException("Converter error") - every { walletAccountListFlowFactory.create(userWallet) } throws exception + every { walletAccountListFlowFactory.create(userWalletId) } throws exception // Act val actual = producer.produceWithFallback().let(::getEmittedValues) @@ -152,7 +152,7 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -178,7 +178,7 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - every { walletAccountListFlowFactory.create(userWallet) } returns emptyFlow() + every { walletAccountListFlowFactory.create(userWalletId) } returns emptyFlow() // Act val actual = producer.produce().let(::getEmittedValues) @@ -188,7 +188,7 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -203,9 +203,9 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(listOf(userWallet, userWallet2)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) - every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) - every { walletAccountListFlowFactory.create(userWallet2) } returns emptyFlow() + val accountList = AccountList.empty(userWalletId) + every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) + every { walletAccountListFlowFactory.create(userWalletId2) } returns emptyFlow() // Act val actual = producer.produce().let(::getEmittedValues) @@ -215,8 +215,8 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - walletAccountListFlowFactory.create(userWallet2) + walletAccountListFlowFactory.create(userWalletId) + walletAccountListFlowFactory.create(userWalletId2) } } } \ No newline at end of file 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 846e46bfea..bc065b4578 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 @@ -2,7 +2,6 @@ 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.account.producer.SingleAccountListProducer import com.tangem.domain.models.TokensSortType @@ -11,7 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -26,7 +24,6 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultSingleAccountListProducerTest { - private val userWalletsStore: UserWalletsStore = mockk() private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() private val userWalletId = UserWalletId("011") @@ -36,24 +33,22 @@ class DefaultSingleAccountListProducerTest { private val producer = DefaultSingleAccountListProducer( params = SingleAccountListProducer.Params(userWalletId = userWalletId), - userWalletsStore = userWalletsStore, walletAccountListFlowFactory = walletAccountListFlowFactory, dispatchers = TestingCoroutineDispatcherProvider(), ) @AfterEach fun tearDownEach() { - clearMocks(userWalletsStore, walletAccountListFlowFactory) + clearMocks(walletAccountListFlowFactory) } @Test fun produce() = runTest { // Arrange - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow + MutableStateFlow(listOf(userWallet)) - val accountList = AccountList.empty(userWallet) - every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + val accountList = AccountList.empty(userWalletId) + every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) // Act val actual = producer.produce().let(::getEmittedValues) @@ -63,22 +58,18 @@ class DefaultSingleAccountListProducerTest { Truth.assertThat(actual).containsExactly(expected) coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @Test fun `flow will updated if factoryFlow is updated`() = runTest { // Arrange - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - val accountList = AccountList.empty(userWallet) - val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val accountList = AccountList.empty(userWalletId) + val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -95,23 +86,18 @@ class DefaultSingleAccountListProducerTest { Truth.assertThat(secondEmission).containsExactly(updatedAccountList) coVerifyOrder { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) + walletAccountListFlowFactory.create(userWalletId) } } @Test fun `flow is filtered the same response`() = runTest { // Arrange - val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -128,71 +114,8 @@ class DefaultSingleAccountListProducerTest { Truth.assertThat(secondEmission).containsExactly(accountList) coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) + walletAccountListFlowFactory.create(userWalletId) } } - - @Test - fun `flow is empty if factory throws exception`() = runTest { - // Arrange - val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - val exception = RuntimeException("Converter error") - every { walletAccountListFlowFactory.create(userWallet) } throws exception - - // Act - val actual = producer.produceWithFallback().let(::getEmittedValues) - - // Assert - Truth.assertThat(actual).isEmpty() // no emissions - - coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - } - } - - @Test - fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { - // Arrange - val userWalletsFlow = emptyFlow>() - every { userWalletsStore.userWallets } returns userWalletsFlow - - // Act - val actual = producer.produce().let(::getEmittedValues) - - // Assert - Truth.assertThat(actual).isEmpty() // no emissions - - coVerify(exactly = 1) { userWalletsStore.userWallets } - coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } - } - - @Test - fun `flow is empty if userWalletsFlow doesn't contains userWalletId from params`() = runTest { - // Arrange - val unknownId = UserWalletId("012") - val unknownWallet = mockk { - every { this@mockk.walletId } returns unknownId - } - - val userWalletsFlow = MutableStateFlow(listOf(unknownWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - // Act - val actual = producer.produce().let(::getEmittedValues) - - // Assert - Truth.assertThat(actual).isEmpty() // no emissions - - coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - } - - coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } - } } \ No newline at end of file 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 6e13e22dad..47c2e8f6fc 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 @@ -10,6 +10,7 @@ import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.common.currency.CardCryptoCurrencyFactory 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.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -28,6 +29,7 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class WalletAccountListFlowFactoryTest { + private val userWalletsStore: UserWalletsStore = mockk() private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() private val accountsResponseStore: AccountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) @@ -38,6 +40,7 @@ class WalletAccountListFlowFactoryTest { private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() private val factory = WalletAccountListFlowFactory( + userWalletsStore = userWalletsStore, accountsResponseStoreFactory = accountsResponseStoreFactory, accountListConverterFactory = accountListConverterFactory, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, @@ -49,6 +52,7 @@ class WalletAccountListFlowFactoryTest { @AfterEach fun tearDownEach() { clearMocks( + userWalletsStore, accountsResponseStoreFactory, accountsResponseStore, accountListConverterFactory, @@ -66,17 +70,19 @@ class WalletAccountListFlowFactoryTest { every { this@mockk.isMultiCurrency } returns true } + every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet + val accountsResponse = createGetWalletAccountsResponse(userWalletId) every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore every { accountsResponseStore.data } returns accountsResponseStoreFlow accountsResponseStoreFlow.value = accountsResponse - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) every { accountListConverterFactory.create(userWallet) } returns accountListConverter every { accountListConverter.convert(accountsResponse) } returns accountList // Act - val actual = factory.create(userWallet).let(::getEmittedValues) + val actual = factory.create(userWalletId).let(::getEmittedValues) // Assert val expected = accountList @@ -99,14 +105,16 @@ class WalletAccountListFlowFactoryTest { fun `create for single wallet`() = runTest { val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false) + every { userWalletsStore.getSyncStrict(userWallet.walletId) } returns userWallet + val currency = cryptoCurrencyFactory.ethereum every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) } returns currency // Act - val actual = factory.create(userWallet).let(::getEmittedValues) + val actual = factory.create(userWallet.walletId).let(::getEmittedValues) // Assert - val expected = AccountList.empty(userWallet = userWallet, cryptoCurrencies = setOf(currency)) + val expected = AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = setOf(currency)) Truth.assertThat(actual).containsExactly(expected) coVerify(ordering = Ordering.SEQUENCE) { @@ -126,16 +134,18 @@ class WalletAccountListFlowFactoryTest { fun `flow is created for single wallet with token`() = runTest { val nodl = MockUserWalletFactory.createSingleWalletWithToken() + every { userWalletsStore.getSyncStrict(nodl.walletId) } returns nodl + val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() every { cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) } returns currencies.toList() // Act - val actual = factory.create(nodl).let(::getEmittedValues) + val actual = factory.create(nodl.walletId).let(::getEmittedValues) // Assert - val expected = AccountList.empty(userWallet = nodl, cryptoCurrencies = currencies) + val expected = AccountList.empty(userWalletId = nodl.walletId, cryptoCurrencies = currencies) Truth.assertThat(actual).containsExactly(expected) coVerify(ordering = Ordering.SEQUENCE) { 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 1e7c97fc64..9507b208c1 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 @@ -25,7 +25,6 @@ import com.tangem.domain.models.account.AccountId 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.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -37,6 +36,7 @@ import kotlin.time.Duration.Companion.minutes /** [REDACTED_AUTHOR] */ +@Suppress("UnusedFlow") @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultAccountsCRUDRepositoryTest { @@ -580,22 +580,18 @@ class DefaultAccountsCRUDRepositoryTest { @Test fun `saveAccounts should call API and update store`() = runTest { // Arrange - val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - } + val accountList = AccountList.empty(userWalletId = userWalletId) + val accounts = accountList.accounts.filterIsInstance() - val accountList = AccountList.empty(userWallet = userWallet) - - val accountsResponse = mockk() + val accountsResponse = createGetWalletAccountsResponse(userWalletId) accountsResponseStoreFlow.value = accountsResponse - val converter = mockk { - every { this@mockk.convert(accountList) } returns accountsResponse + val converter = mockk { + every { this@mockk.convertListBack(accounts) } returns accountsResponse.accounts } - every { - convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) - } returns converter + every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns converter + coEvery { walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } returns accountsResponse // Act repository.saveAccounts(accountList) @@ -604,35 +600,30 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse) coVerifyOrder { - convertersContainer.getWalletAccountsResponseCF.create(userWallet) - converter.convert(accountList) - walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) + convertersContainer.createCryptoPortfolioConverter(userWalletId) + converter.convertListBack(accounts) + walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } } @Test fun `saveAccounts if API request is failed`() = runTest { // Arrange - val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - } + val accountList = AccountList.empty(userWalletId = userWalletId) + val accounts = accountList.accounts.filterIsInstance() - val accountList = AccountList.empty(userWallet = userWallet) - - val accountsResponse = mockk() + val accountsResponse = createGetWalletAccountsResponse(userWalletId) accountsResponseStoreFlow.value = accountsResponse - val converter = mockk { - every { this@mockk.convert(accountList) } returns accountsResponse + val converter = mockk { + every { this@mockk.convertListBack(accounts) } returns accountsResponse.accounts } - every { - convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) - } returns converter + every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns converter val exception = Exception("Test error") - coEvery { walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) } throws exception + coEvery { walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } throws exception // Act val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!! @@ -642,9 +633,8 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) coVerifyOrder { - convertersContainer.getWalletAccountsResponseCF.create(userWallet) - converter.convert(accountList) - walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) + convertersContainer.createCryptoPortfolioConverter(userWalletId) + converter.convertListBack(accounts) } } } 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 new file mode 100644 index 0000000000..e270dacc98 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt @@ -0,0 +1,240 @@ +package com.tangem.data.account.utils + +import com.google.common.truth.Truth +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.converter.createWalletAccountDTO +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +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.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultWalletAccountsResponseFactoryTest { + + private val userWalletsListRepository = mockk() + private val cryptoPortfolioCF = mockk() + private val cryptoPortfolioConverter = mockk() + private val userTokensResponseFactory = mockk() + private val cardCryptoCurrencyFactory = mockk() + + private val factory = DefaultWalletAccountsResponseFactory( + userWalletsListRepository = userWalletsListRepository, + cryptoPortfolioCF = cryptoPortfolioCF, + userTokensResponseFactory = userTokensResponseFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWalletId = UserWalletId("011") + + @BeforeEach + fun setUpEach() { + every { cryptoPortfolioCF.create(any()) } returns cryptoPortfolioConverter + } + + @AfterEach + fun tearDownEach() { + clearMocks( + userWalletsListRepository, + cryptoPortfolioCF, + cryptoPortfolioConverter, + userTokensResponseFactory, + cardCryptoCurrencyFactory, + ) + } + + @Test + fun `create returns empty accounts when user wallet not found`() = runTest { + // Arrange + val userTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = emptyList(), + ) + + coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns userTokensResponse + + // Act + val actual = factory.create(userWalletId = userWalletId, userTokensResponse = null) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + userWalletsListRepository.userWalletsSync() + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + } + + @Test + fun `create returns response with default tokens when userTokensResponse is null`() = runTest { + // Arrange + val userWallet = mockk(relaxed = true) { + 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 + + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + + val accountsDTO = createWalletAccountDTO(userWalletId) + every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) + + // Act + val actual = factory.create(userWalletId, null) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = defaultResponse.group, + sort = defaultResponse.sort, + totalAccounts = 1, + ), + accounts = listOf(accountsDTO), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + userWalletsListRepository.userWalletsSync() + cryptoPortfolioConverter.convertListBack(accounts) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) + userTokensResponseFactory.createUserTokensResponse( + currencies = defaultCoins, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + } + + @Test + fun `create returns response with default tokens when userTokensResponse is null and no default coins`() = runTest { + // Arrange + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + val defaultResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = emptyList(), + ) + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns defaultResponse + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList() + + // Act + val actual = factory.create(userWalletId, null) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = defaultResponse.group, + sort = defaultResponse.sort, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `create returns response with assigned tokens`() = runTest { + // Arrange + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + val assignedTokens = listOf(mockk(), mockk()) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + val userTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = listOf(mockk(relaxed = true)), + ) + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = assignedTokens, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns userTokensResponse + + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + val accountsDTO = createWalletAccountDTO(userWalletId) + every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) + + // Act + val actual = factory.create(userWalletId, userTokensResponse) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = userTokensResponse.group, + sort = userTokensResponse.sort, + totalAccounts = 1, + ), + accounts = listOf(accountsDTO), + unassignedTokens = emptyList(), + ) + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt index f4b684613b..de6c29ccc4 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt @@ -12,18 +12,14 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface WalletAccountsSaver { - /** Push and store wallet accounts [response] by [userWalletId] */ - @Throws - suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) - /** Store wallet accounts [response] by [userWalletId] */ suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) /** Push wallet accounts [body] by [userWalletId] */ @Throws - suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) + suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse): GetWalletAccountsResponse? /** Push wallet accounts [accounts] by [userWalletId] */ @Throws - suspend fun push(userWalletId: UserWalletId, accounts: List) + suspend fun push(userWalletId: UserWalletId, accounts: List): GetWalletAccountsResponse? } \ No newline at end of file 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 c7aff7add9..94fb83724a 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 @@ -326,6 +326,8 @@ class NetworkFactory @Inject constructor( Blockchain.Pepecoin, Blockchain.PepecoinTestnet, Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, Blockchain.Quai, Blockchain.QuaiTestnet, + Blockchain.Linea, Blockchain.LineaTestnet, + Blockchain.ArbitrumNova, -> Network.TransactionExtrasType.NONE // endregion } diff --git a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt index 6bf4e36673..e2f4c82b9c 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt @@ -8,6 +8,7 @@ import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.networks.repository.DefaultNetworksRepository import com.tangem.data.networks.store.DefaultNetworksStatusesStore import com.tangem.data.networks.store.NetworksStatusesStore +import com.tangem.data.networks.utils.DefaultNetworksCleaner import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.network.entity.NetworkStatusDM @@ -15,6 +16,7 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.datasource.utils.setTypes import com.tangem.domain.networks.repository.NetworksRepository +import com.tangem.domain.networks.utils.NetworksCleaner import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -67,4 +69,18 @@ internal object NetworkDataModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideNetworksCleaner( + networksStatusesStore: NetworksStatusesStore, + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): NetworksCleaner { + return DefaultNetworksCleaner( + networksStatusesStore = networksStatusesStore, + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt index ff25a5ad51..213a73be50 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt @@ -96,6 +96,20 @@ internal class DefaultNetworksStatusesStore( } } + override suspend fun clear(userWalletId: UserWalletId, networks: Set) { + persistenceDataStore.updateData { storedStatuses -> + storedStatuses.toMutableMap().apply { + val updatedValues = this[userWalletId.stringValue].orEmpty().filterNot { + networks.any { network -> + it.networkId.value == network.rawId && it.derivationPath.value == network.derivationPath.value + } + } + + this[userWalletId.stringValue] = updatedValues.toSet() + } + } + } + private suspend fun updateInRuntime( userWalletId: UserWalletId, networks: Set, diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt index 30246ff85c..680ee077ab 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt @@ -41,4 +41,7 @@ internal interface NetworksStatusesStore { * See complex methods in `NetworksStatusesStoreExt`. */ suspend fun store(userWalletId: UserWalletId, status: NetworkStatus) + + /** Clear statuses of [networks] by [userWalletId] */ + suspend fun clear(userWalletId: UserWalletId, networks: Set) } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt new file mode 100644 index 0000000000..c4ef782bc9 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt @@ -0,0 +1,73 @@ +package com.tangem.data.networks.utils + +import com.tangem.data.networks.store.NetworksStatusesStore +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.utils.NetworksCleaner +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Default implementation of [NetworksCleaner]. + * + * @property networksStatusesStore Store to manage network statuses. + * @property walletManagersFacade Facade to manage wallet managers. + * @property dispatchers Coroutine dispatchers provider. + * +[REDACTED_AUTHOR] + */ +internal class DefaultNetworksCleaner( + private val networksStatusesStore: NetworksStatusesStore, + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : NetworksCleaner { + + override suspend fun invoke(userWalletId: UserWalletId, currencies: List) { + withContext(dispatchers.default) { + val (networks, tokens) = currencies.partitionByType() + + coroutineScope { + launch { cleanStore(userWalletId = userWalletId, networks = networks) } + launch { cleanWalletManager(userWalletId = userWalletId, networks = networks, tokens = tokens) } + } + } + } + + private suspend fun cleanStore(userWalletId: UserWalletId, networks: Set) { + if (networks.isNotEmpty()) { + networksStatusesStore.clear(userWalletId = userWalletId, networks = networks) + } + } + + private suspend fun cleanWalletManager( + userWalletId: UserWalletId, + networks: Set, + tokens: Set, + ) { + if (networks.isNotEmpty()) { + walletManagersFacade.remove(userWalletId = userWalletId, networks = networks) + } + + if (tokens.isNotEmpty()) { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens) + } + } + + private fun List.partitionByType(): Pair, Set> { + val networks = mutableSetOf() + val tokens = mutableSetOf() + + for (currency in this) { + when (currency) { + is CryptoCurrency.Coin -> networks.add(currency.network) + is CryptoCurrency.Token -> tokens.add(currency) + } + } + + return Pair(networks, tokens) + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt new file mode 100644 index 0000000000..c2913c5f5a --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt @@ -0,0 +1,101 @@ +package com.tangem.data.networks.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.networks.store.NetworksStatusesStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultNetworksCleanerTest { + + private val networksStatusesStore = mockk(relaxed = true) + private val walletManagersFacade = mockk(relaxed = true) + private val cleaner = DefaultNetworksCleaner( + networksStatusesStore = networksStatusesStore, + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val network = cryptoCurrencyFactory.ethereum.network + private val coin = cryptoCurrencyFactory.ethereum + private val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + + @BeforeEach + fun setUp() { + clearMocks(networksStatusesStore, walletManagersFacade) + } + + @Test + fun `should clear networks and remove managers and tokens when called`() = runTest { + // Arrange + val currencies = listOf(coin, token) + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerifyOrder { + networksStatusesStore.clear(userWalletId, setOf(network)) + walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } + } + + @Test + fun `should handle empty currencies`() = runTest { + // Act + cleaner(userWalletId = userWalletId, currencies = emptyList()) + + // Assert + coVerifyOrder(inverse = true) { + networksStatusesStore.clear(userWalletId = any(), networks = any()) + walletManagersFacade.remove(userWalletId = any(), networks = any()) + walletManagersFacade.removeTokens(userWalletId = any(), tokens = any()) + } + } + + @Test + fun `should clear only networks when there are no tokens`() = runTest { + val currencies = listOf(coin) + + cleaner(userWalletId = userWalletId, currencies = currencies) + + coVerifyOrder { + networksStatusesStore.clear(userWalletId, setOf(network)) + walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) + } + + coVerifyOrder(inverse = true) { + walletManagersFacade.removeTokens(userWalletId = any(), tokens = any()) + } + } + + @Test + fun `should clear only tokens when there are no networks`() = runTest { + // Arrange + val currencies = listOf(token) + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerifyOrder { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } + + coVerifyOrder(inverse = true) { + networksStatusesStore.clear(userWalletId = any(), networks = any()) + walletManagersFacade.remove(userWalletId = any(), networks = any()) + } + } +} \ No newline at end of file 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 fe654114a6..a535b536db 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt @@ -160,5 +160,7 @@ public val Blockchain.mercuryoNetwork: String? Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> null Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> null Blockchain.Quai, Blockchain.QuaiTestnet -> null + Blockchain.Linea, Blockchain.LineaTestnet -> null + Blockchain.ArbitrumNova -> null } } \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index 5412605960..9c1b8171f0 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -7,6 +7,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.get 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.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.usercountry.models.GB_COUNTRY @@ -91,11 +92,16 @@ internal class DefaultSettingsRepository( } override suspend fun shouldSaveAccessCodes(): Boolean { - return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, default = false) + return appPreferencesStore.getSyncOrNull(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY)?.not() + ?: appPreferencesStore.getSyncOrDefault( + key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, + default = false, + ) } override suspend fun setShouldSaveAccessCodes(value: Boolean) { appPreferencesStore.store(key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, value = value) + appPreferencesStore.store(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, value = value.not()) } override suspend fun incrementAppLaunchCounter() { 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 a5c5a41e2b..d96e876a73 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 @@ -10,6 +10,7 @@ import com.tangem.data.staking.DefaultStakingTransactionHashRepository 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.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse import com.tangem.datasource.di.NetworkMoshi @@ -21,6 +22,7 @@ import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.staking.repositories.StakingTransactionHashRepository import com.tangem.domain.staking.toggles.StakingFeatureToggles +import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -102,4 +104,16 @@ internal object StakingDataModule { fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): StakingFeatureToggles { return DefaultStakingFeatureToggles(featureTogglesManager) } + + @Provides + @Singleton + fun provideStakingCleaner( + yieldsBalancesStore: YieldsBalancesStore, + dispatchers: CoroutineDispatcherProvider, + ): StakingCleaner { + return DefaultStakingCleaner( + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt index 40fbee1534..fa753503be 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt @@ -96,6 +96,16 @@ internal class DefaultYieldsBalancesStore( ) } + override suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) { + persistenceStore.updateData { current -> + current.toMutableMap().apply { + this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() + .filterNot { it.getStakingId() in stakingIds } + .toSet() + } + } + } + private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set) { val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values) .filterNotNull() diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt index 3ec260a07f..10bfa99242 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt @@ -33,4 +33,7 @@ interface YieldsBalancesStore { /** Store error by [userWalletId] and [stakingIds] */ suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) + + /** Clear balances of [stakingIds] by [userWalletId] */ + suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt new file mode 100644 index 0000000000..1aa6753150 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt @@ -0,0 +1,29 @@ +package com.tangem.data.staking.utils + +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * Default implementation of [StakingCleaner]. + * + * @property yieldsBalancesStore Store to manage yields balances. + * @property dispatchers Coroutine dispatchers provider. + * +[REDACTED_AUTHOR] + */ +internal class DefaultStakingCleaner( + private val yieldsBalancesStore: YieldsBalancesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : StakingCleaner { + + override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set) { + if (stakingIds.isEmpty()) return + + with(dispatchers.default) { + yieldsBalancesStore.clear(userWalletId, stakingIds) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..08dc91f9d6 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt @@ -0,0 +1,55 @@ +package com.tangem.data.staking.utils + +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultStakingCleanerTest { + + private val yieldsBalancesStore = mockk(relaxed = true) + private val cleaner = DefaultStakingCleaner( + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + private val userWalletId = UserWalletId("011") + private val stakingIds = setOf( + StakingID(integrationId = StakingIntegrationID.Coin.Cardano.value, address = "0x1"), + ) + + @BeforeEach + fun setUp() { + clearMocks(yieldsBalancesStore) + } + + @Test + fun `should clear yields balances when called`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerifyOrder { + yieldsBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } + } + + @Test + fun `should handle empty stakingIds`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = emptySet()) + + // Assert + coVerifyOrder(inverse = true) { + yieldsBalancesStore.clear(userWalletId = any(), stakingIds = any()) + } + } +} \ No newline at end of file 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 ce7c5ddb13..80b1a4577d 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 @@ -74,77 +74,18 @@ internal class DefaultCurrenciesRepository( userTokensSaver.storeAndPush(userWalletId, response) } - override suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List) { + 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 newCurrencies = populateCurrenciesWithMissedCoins(currencies) - val updatedResponse = savedResponse.copy( - tokens = newCurrencies.map(userTokensResponseFactory::createResponseToken), - ) - userTokensSaver.storeAndPush( - userWalletId = userWalletId, - response = updatedResponse, + tokens = currencies.map(userTokensResponseFactory::createResponseToken), ) - fetchExpressAssetsByNetworkIds( - userWallet = userWalletsStore.getSyncStrict(key = userWalletId), - userTokens = updatedResponse, - ) - } - } - - override suspend fun addCurrencies( - userWalletId: UserWalletId, - currencies: List, - ): List = withContext(dispatchers.io) { - val savedCurrencies = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWalletId), - lazyMessage = { "Saved tokens empty. Can not perform add currencies action" }, - ) - - val currenciesToAdd = filterAlreadyAddedCurrencies( - savedCurrencies = savedCurrencies.tokens, - currenciesToAdd = populateCurrenciesWithMissedCoins(currencies = currencies), - ) - - val updatedResponse = savedCurrencies.copy( - tokens = savedCurrencies.tokens + currenciesToAdd.map(userTokensResponseFactory::createResponseToken), - ) - - userTokensSaver.storeAndPush( - userWalletId = userWalletId, - response = updatedResponse, - ) - - fetchExpressAssetsByNetworkIds( - userWallet = userWalletsStore.getSyncStrict(key = userWalletId), - userTokens = updatedResponse, - ) - - currenciesToAdd - } - - override suspend fun saveNewCurrenciesListCache(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 newCurrencies = populateCurrenciesWithMissedCoins(currencies) - - val updatedResponse = savedResponse.copy( - tokens = newCurrencies.map(userTokensResponseFactory::createResponseToken), - ) - userTokensSaver.store( - userWalletId = userWalletId, - response = updatedResponse, - ) + userTokensSaver.store(userWalletId = userWalletId, response = updatedResponse) fetchExpressAssetsByNetworkIds( userWallet = userWalletsStore.getSyncStrict(key = userWalletId), @@ -551,6 +492,10 @@ internal class DefaultCurrenciesRepository( } } + override fun createCoinCurrency(network: Network): CryptoCurrency.Coin { + return cryptoCurrencyFactory.createCoin(network = network) + } + override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token { return cryptoCurrencyFactory.createToken( cryptoCurrency = cryptoCurrency, 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 b005177a40..d0d1af52ab 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 @@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext import javax.inject.Inject private const val VALID_STATUS = "valid" +private const val APPROVED_KYC_STATUS = "APPROVED" private const val TAG = "TangemPay: OnboardingRepository" internal class DefaultOnboardingRepository @Inject constructor( @@ -47,13 +48,36 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun getMainScreenCustomerInfo(): Either { return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.requestWithPersistedToken { authHeader -> - tangemPayApi.getCustomerMe(authHeader) - }.result + val customerWalletAddress = requestHelper.getCustomerWalletAddress() - val orderStatus = getOrderStatus().getOrNull() ?: error("Order status is null") + when (val orderId = tangemPayStorage.getOrderId(customerWalletAddress)) { + // If order id wasn't saved -> get customer info + null -> { + MainScreenCustomerInfo( + info = getCustomerInfoWithPersistedToken(), + orderStatus = OrderStatus.UNKNOWN, + ) + } + // If order id was saved -> check its status + else -> { + val orderStatus = getOrderStatus(orderId) + val customerInfo = when (orderStatus) { + // Kyc is passed and user waits for order creation -> no need to get customer info + OrderStatus.NEW, + OrderStatus.PROCESSING, + -> CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null) - MainScreenCustomerInfo(info = getCustomerInfo(result), orderStatus = orderStatus) + // Order was created/cancelled -> clear order id and get customer info + OrderStatus.UNKNOWN, + OrderStatus.COMPLETED, + OrderStatus.CANCELED, + -> getCustomerInfoWithPersistedToken().also { + tangemPayStorage.clearOrderId(customerWalletAddress) + } + } + MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus) + } + } } } @@ -84,27 +108,28 @@ internal class DefaultOnboardingRepository @Inject constructor( } return CustomerInfo( productInstance = response?.productInstance?.let { ProductInstance(id = it.id, status = it.status) }, - kycStatus = response?.kyc?.status, + isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS, cardInfo = cardInfo, ) } - private suspend fun getOrderStatus(): Either { - return requestHelper.runWithErrorLogs(TAG) { - val walletAddress = requestHelper.getCustomerWalletAddress() - val orderId: String = tangemPayStorage.getOrderId(walletAddress) - ?: return@runWithErrorLogs OrderStatus.NOT_ISSUED + private suspend fun getOrderStatus(orderId: String): OrderStatus { + val result = requestHelper.request { authHeader -> + tangemPayApi.getOrder(authHeader, orderId) + }.result ?: error("Order result is null") - val result = requestHelper.request { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - }.result ?: error("Order result is null") - - when (result.status) { - OrderStatus.NEW.apiName -> OrderStatus.NEW - OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING - OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED - else -> OrderStatus.CANCELED - } + return when (result.status) { + OrderStatus.NEW.apiName -> OrderStatus.NEW + OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING + OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED + else -> OrderStatus.CANCELED } } + + private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo { + val result = requestHelper.requestWithPersistedToken { authHeader -> + tangemPayApi.getCustomerMe(authHeader) + }.result + return getCustomerInfo(result) + } } \ 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 be43b2bd72..7e197fea52 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 @@ -86,6 +86,6 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( }.result val items = TangemPayTxHistoryItemConverter.convertList(result.transactions).filterNotNull() txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items) - } + }.onLeft { error(it.toString()) } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt index defb6559b9..e8bead6d27 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 @@ -30,7 +30,7 @@ internal object TangemPayTxHistoryItemConverter : merchantName = spend.merchantName, enrichedMerchantCategory = spend.enrichedMerchantCategory, merchantCategory = spend.merchantCategory, - status = spend.status, + status = TangemPayTxHistoryItemStatusConverter.convert(spend.status), enrichedMerchantIconUrl = spend.enrichedMerchantIcon, ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt new file mode 100644 index 0000000000..775b3843a9 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt @@ -0,0 +1,16 @@ +package com.tangem.data.visa.utils + +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.utils.converter.Converter + +internal object TangemPayTxHistoryItemStatusConverter : Converter { + override fun convert(value: String): TangemPayTxHistoryItem.Status { + return when (value.uppercase()) { + "PENDING" -> TangemPayTxHistoryItem.Status.PENDING + "RESERVED" -> TangemPayTxHistoryItem.Status.RESERVED + "COMPLETED" -> TangemPayTxHistoryItem.Status.COMPLETED + "DECLINED" -> TangemPayTxHistoryItem.Status.DECLINED + else -> TangemPayTxHistoryItem.Status.UNKNOWN + } + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt deleted file mode 100644 index 8abd538e91..0000000000 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.tangem.data.yield.supply - -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.local.yieldsupply.YieldMarketsStore -import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter -import com.tangem.datasource.api.tangemTech.YieldSupplyApi -import com.tangem.data.yield.supply.converters.YieldTokenStatusConverter -import com.tangem.data.yield.supply.converters.YieldTokenChartConverter -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository -import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus -import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext -import kotlin.collections.map - -internal class DefaultYieldSupplyMarketRepository( - private val yieldSupplyApi: YieldSupplyApi, - private val store: YieldMarketsStore, - private val dispatchers: CoroutineDispatcherProvider, -) : YieldSupplyMarketRepository { - - override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { - store.getSyncOrNull()?.enrichNetworkIds() - } - - override suspend fun updateMarkets(): List = withContext(dispatchers.io) { - val response = yieldSupplyApi.getYieldMarkets().getOrThrow() - val domain = response.marketDtos.map(YieldMarketTokenConverter::convert) - store.store(domain) - domain - } - - override fun getMarketsFlow(): Flow> = store.get().map { - it.enrichNetworkIds() - } - - override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() - ?: error("Chain id is required for evm's") - val response = yieldSupplyApi.getYieldTokenStatus(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() - return YieldTokenStatusConverter.convert(response) - } - - override suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() - ?: error("Chain id is required for evm's") - val response = yieldSupplyApi.getYieldTokenChart(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() - return YieldTokenChartConverter.convert(response) - } - - private fun List.enrichNetworkIds(): List { - val chainIdMap = Blockchain.entries.associate { it.getChainId() to it.toNetworkId() } - return this.map { token -> - token.copy(backendId = chainIdMap[token.chainId]) - } - } -} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt new file mode 100644 index 0000000000..8d8613e976 --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -0,0 +1,106 @@ +package com.tangem.data.yield.supply + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.yieldsupply.YieldSupplyProvider +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.local.yieldsupply.YieldMarketsStore +import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter +import com.tangem.datasource.api.tangemTech.YieldSupplyApi +import com.tangem.data.yield.supply.converters.YieldTokenChartConverter +import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyRepository +import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.collections.map + +internal class DefaultYieldSupplyRepository( + private val yieldSupplyApi: YieldSupplyApi, + private val store: YieldMarketsStore, + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldSupplyRepository { + + override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { + val cache = store.getSyncOrNull().orEmpty() + val domain = cache.map(YieldMarketTokenConverter::convert) + domain.enrichNetworkIds() + } + + override suspend fun updateMarkets(): List = withContext(dispatchers.io) { + val chains = Blockchain.yieldSupplySupportedBlockchains().map { it.getChainId() }.joinToString(",") + val response = yieldSupplyApi.getYieldMarkets(chainId = chains).getOrThrow() + val domain = response.marketDtos.map(YieldMarketTokenConverter::convert) + store.store(response.marketDtos) + domain + } + + override fun getMarketsFlow(): Flow> = store.get().map { + it.map(YieldMarketTokenConverter::convert).enrichNetworkIds() + } + + override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketToken { + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + ?: error("Chain id is required for evm's") + val response = yieldSupplyApi.getYieldTokenStatus(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() + return YieldMarketTokenConverter.convert(response) + } + + override suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData { + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + ?: error("Chain id is required for evm's") + val response = yieldSupplyApi.getYieldTokenChart(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() + return YieldTokenChartConverter.convert(response) + } + + override suspend fun isYieldSupplySupported(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean = + withContext(dispatchers.io) { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + (walletManager as? YieldSupplyProvider)?.isSupported() ?: false + } + + override suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean = + withContext(dispatchers.io) { + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + ?: error("Chain id is required for evm's") + yieldSupplyApi.activateYieldModule( + YieldSupplyChangeTokenStatusBody( + tokenAddress = cryptoCurrencyToken.contractAddress, + chainId = chainId, + ), + ).getOrThrow().isActive + } + + override suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean = + withContext(dispatchers.io) { + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + ?: error("Chain id is required for evm's") + yieldSupplyApi.deactivateYieldModule( + YieldSupplyChangeTokenStatusBody( + tokenAddress = cryptoCurrencyToken.contractAddress, + chainId = chainId, + ), + ).getOrThrow().isActive + } + + private fun List.enrichNetworkIds(): List { + val chainIdMap = Blockchain.entries.associate { it.getChainId() to it.toNetworkId() } + return this.map { token -> + token.copy(backendId = chainIdMap[token.chainId]) + } + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt index ca069668ff..c66fb73edd 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt @@ -1,16 +1,19 @@ package com.tangem.data.yield.supply.converters -import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero -internal object YieldMarketTokenConverter : Converter { - override fun convert(value: YieldMarketsResponse.MarketDto): YieldMarketToken { +internal object YieldMarketTokenConverter : Converter { + override fun convert(value: YieldSupplyMarketTokenDto): YieldMarketToken { return YieldMarketToken( tokenAddress = value.tokenAddress.orEmpty(), - apy = value.apy, - isActive = value.isActive, + apy = value.apy.orZero(), + isActive = value.isActive ?: false, chainId = value.chainId ?: -1, + maxFeeUSD = value.maxFeeUSD.orEmpty(), + maxFeeNative = value.maxFeeNative.orEmpty(), ) } } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldTokenStatusConverter.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldTokenStatusConverter.kt deleted file mode 100644 index 8035634fc9..0000000000 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldTokenStatusConverter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.data.yield.supply.converters - -import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse -import com.tangem.domain.models.serialization.SerializedBigDecimal -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus -import com.tangem.utils.converter.Converter - -internal object YieldTokenStatusConverter : Converter { - override fun convert(value: YieldTokenStatusResponse): YieldMarketTokenStatus { - return YieldMarketTokenStatus( - tokenAddress = value.tokenAddress.orEmpty(), - tokenSymbol = value.tokenSymbol.orEmpty(), - tokenName = value.tokenName.orEmpty(), - apy = value.apy ?: SerializedBigDecimal.ZERO, - isActive = value.isActive ?: false, - chainId = value.chainId ?: -1, - maxFeeUSD = value.maxFeeUSD.orEmpty(), - maxFeeNative = value.maxFeeNative.orEmpty(), - ) - } -} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index 8888eba918..69ce23f67e 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -1,12 +1,12 @@ package com.tangem.data.yield.supply.di -import com.tangem.data.yield.supply.DefaultYieldSupplyMarketRepository +import com.tangem.data.yield.supply.DefaultYieldSupplyRepository import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyErrorResolver import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -37,12 +37,14 @@ internal object YieldSupplyDataModule { fun provideYieldSupplyMarketRepository( yieldSupplyApi: YieldSupplyApi, store: YieldMarketsStore, + walletManagersFacade: WalletManagersFacade, dispatchers: CoroutineDispatcherProvider, - ): YieldSupplyMarketRepository { - return DefaultYieldSupplyMarketRepository( + ): YieldSupplyRepository { + return DefaultYieldSupplyRepository( yieldSupplyApi = yieldSupplyApi, store = store, dispatchers = dispatchers, + walletManagersFacade = walletManagersFacade, ) } 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 2f848f2f16..e305566047 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 @@ -8,14 +8,14 @@ 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.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.extensions.addOrReplace import kotlinx.serialization.Serializable /** * Represents a list of accounts associated with a user wallet * - * @property userWallet the user wallet associated with the account list + * @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 * @@ -23,7 +23,7 @@ import kotlinx.serialization.Serializable */ @Serializable data class AccountList private constructor( - val userWallet: UserWallet, + val userWalletId: UserWalletId, val accounts: Set, val totalAccounts: Int, val sortType: TokensSortType, @@ -51,7 +51,7 @@ data class AccountList private constructor( val accounts = this.accounts.addOrReplace(other) { it.accountId == other.accountId } return invoke( - userWallet = this.userWallet, + userWalletId = this.userWalletId, accounts = accounts, totalAccounts = this.totalAccounts + if (isNewAccount) 1 else 0, sortType = this.sortType, @@ -73,7 +73,7 @@ data class AccountList private constructor( } return invoke( - userWallet = this.userWallet, + userWalletId = this.userWalletId, accounts = accounts, totalAccounts = this.totalAccounts - if (isExistingAccount) 1 else 0, sortType = this.sortType, @@ -134,12 +134,12 @@ data class AccountList private constructor( * Factory method to create an `AccountList` instance. * Validates the input to ensure the accounts list is not empty and contains exactly one main account. * - * @param userWallet the user wallet associated with the account list + * @param userWalletId the user wallet id associated with the account list * @param accounts a set of accounts belonging to the user wallet * @param totalAccounts the total number of accounts */ operator fun invoke( - userWallet: UserWallet, + userWalletId: UserWalletId, accounts: Set, totalAccounts: Int, sortType: TokensSortType = TokensSortType.NONE, @@ -169,7 +169,7 @@ data class AccountList private constructor( } AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = accounts, totalAccounts = totalAccounts, sortType = sortType, @@ -180,19 +180,19 @@ data class AccountList private constructor( /** * Factory method to create an empty [AccountList] with a main crypto portfolio account * - * @param userWallet the user wallet associated with the account list + * @param userWalletId the user wallet id associated with the account list */ fun empty( - userWallet: UserWallet, + userWalletId: UserWalletId, cryptoCurrencies: Set = emptySet(), sortType: TokensSortType = TokensSortType.NONE, groupType: TokensGroupType = TokensGroupType.NONE, ): AccountList { return AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf( Account.CryptoPortfolio.createMainAccount( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, cryptoCurrencies = cryptoCurrencies, ), ), 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 9581d1a6fb..4d3b4dab0e 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 @@ -3,13 +3,13 @@ package com.tangem.domain.account.models import com.tangem.domain.models.TotalFiatBalance 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 kotlinx.serialization.Serializable /** * Represents a list of account statuses associated with a user wallet * - * @property userWallet the user wallet to which the account statuses belong + * @property userWalletId the user wallet id to which the account statuses belong * @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 @@ -18,7 +18,7 @@ import kotlinx.serialization.Serializable */ @Serializable data class AccountStatusList( - val userWallet: UserWallet, + val userWalletId: UserWalletId, val accountStatuses: Set, val totalAccounts: Int, val totalFiatBalance: TotalFiatBalance, 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 e58992d600..5eb7a0433a 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 @@ -68,6 +68,13 @@ interface AccountsCRUDRepository { */ suspend fun saveAccounts(accountList: AccountList) + /** + * Save account + * + * @param account account to be saved + */ + suspend fun saveAccount(account: Account.CryptoPortfolio) + /** * Retrieves the total count of accounts associated with a specific user wallet including archived accounts * diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt index 1bff615daf..8e52598bb2 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt @@ -5,9 +5,11 @@ 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 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.tokens.MainAccountTokensMigration import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId @@ -16,11 +18,13 @@ import com.tangem.domain.models.wallet.UserWalletId * Use case for recovering a crypto portfolio account from archived accounts * * @property crudRepository repository for performing CRUD operations on accounts + * @property mainAccountTokensMigration handles the migration of tokens from the main account to the recovered account * [REDACTED_AUTHOR] */ class RecoverCryptoPortfolioUseCase( private val crudRepository: AccountsCRUDRepository, + private val mainAccountTokensMigration: MainAccountTokensMigration, ) { /** @@ -30,15 +34,25 @@ class RecoverCryptoPortfolioUseCase( */ suspend operator fun invoke(accountId: AccountId): Either = either { val accountList = getAccountList(userWalletId = accountId.userWalletId) + + ensure(accountList.canAddMoreAccounts) { + raise(Error.AccountListRequirementsNotMet(cause = AccountList.Error.ExceedsMaxAccountsCount)) + } + val archivedAccount = getArchivedAccount(accountId = accountId) val recoveredAccount = archivedAccount.recover() val updatedAccountList = (accountList + recoveredAccount) - .getOrElse { raise(Error.CriticalTechError.AccountListRequirementsNotMet(cause = it)) } + .getOrElse { raise(Error.AccountListRequirementsNotMet(cause = it)) } saveAccounts(updatedAccountList) + mainAccountTokensMigration.migrate( + userWalletId = accountId.userWalletId, + derivationIndex = recoveredAccount.derivationIndex, + ) + recoveredAccount } @@ -47,7 +61,9 @@ class RecoverCryptoPortfolioUseCase( block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) - .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } + .getOrElse { + raise(Error.DataOperationFailed(message = "Account list not found for wallet $userWalletId")) + } } private suspend fun Raise.getArchivedAccount(accountId: AccountId): ArchivedAccount { @@ -56,7 +72,7 @@ class RecoverCryptoPortfolioUseCase( catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { - raise(Error.CriticalTechError.AccountNotFound(accountId = accountId)) + raise(Error.DataOperationFailed(message = "Account not found: $accountId")) } } @@ -66,7 +82,6 @@ class RecoverCryptoPortfolioUseCase( accountName = this.name, icon = this.icon, derivationIndex = this.derivationIndex, - // TODO: [REDACTED_JIRA] cryptoCurrencies = emptySet(), ) } @@ -87,37 +102,18 @@ class RecoverCryptoPortfolioUseCase( get() = this::class.simpleName ?: "RecoverCryptoPortfolioUseCase.Error" /** - * Critical technical errors that can occur during the recovery operation + * Error indicating that the account list requirements were not met. + * + * @property cause the underlying cause of the error */ - sealed interface CriticalTechError : Error { - - /** - - * - * @property userWalletId the unique identifier of the user wallet - */ - data class AccountsNotCreated(val userWalletId: UserWalletId) : CriticalTechError { - override fun toString(): String = "$tag: Accounts for $userWalletId are not created" - } - - /** Error indicating that the account with [accountId] was not found */ - data class AccountNotFound(val accountId: AccountId) : CriticalTechError { - override fun toString(): String = "$tag: Account with ID $accountId not found" - } - - /** - * Error indicating that the account list requirements were not met. - * - * @property cause the underlying cause of the error - */ - data class AccountListRequirementsNotMet(val cause: AccountList.Error) : Error { - override fun toString(): String = "$tag: Account list requirements not met: $cause" - } + data class AccountListRequirementsNotMet(val cause: AccountList.Error) : Error { + override fun toString(): String = "$tag: Account list requirements not met: $cause" } /** Error indicating that a data operation failed */ data class DataOperationFailed(val cause: Throwable) : Error { - override fun toString(): String = "$tag: Data operation failed: ${cause.message ?: "Unknown error"}" + + constructor(message: String) : this(cause = IllegalStateException(message)) } } } \ No newline at end of file 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 0c43198fa0..60eea11794 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 @@ -8,11 +8,7 @@ import com.tangem.domain.account.utils.createAccounts 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.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import io.mockk.clearMocks -import io.mockk.mockk -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -31,7 +27,7 @@ class AccountListTest { val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) val accountList = AccountList( - userWallet = mockk(), + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ) @@ -49,13 +45,13 @@ class AccountListTest { fun canAddMoreAccounts() { // Arrange val accountList = AccountList( - userWallet = mockk(), + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 2), totalAccounts = 2, ).getOrNull()!! val fullAccountList = AccountList( - userWallet = mockk(), + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 20), totalAccounts = 20, ).getOrNull()!! @@ -67,16 +63,13 @@ class AccountListTest { @Test fun empty() { - // Arrange - val userWallet = mockk(relaxed = true) - // Act - val actual = AccountList.empty(userWallet) + val actual = AccountList.empty(userWalletId) // Assert val expected = AccountList( - userWallet = userWallet, - accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)), + userWalletId = userWalletId, + accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), totalAccounts = 1, ).getOrNull()!! @@ -87,19 +80,12 @@ class AccountListTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Create { - private val userWallet = mockk() - - @BeforeEach - fun resetMocks() { - clearMocks(userWallet) - } - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: CreateTestModel) { // Act val actual = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = model.accounts, totalAccounts = model.accounts.size, ) @@ -131,13 +117,13 @@ class AccountListTest { createAccounts(userWalletId = userWalletId, count = 1).let { CreateTestModel( accounts = it, - expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 1), + expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 1), ) }, createAccounts(userWalletId = userWalletId, count = 20).let { CreateTestModel( accounts = it, - expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 20), + expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 20), ) }, CreateTestModel( @@ -171,8 +157,6 @@ class AccountListTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Plus { - private val userWallet = mockk() - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: PlusTestModel) { @@ -191,13 +175,13 @@ class AccountListTest { PlusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toAdd = newAccount, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount, newAccount), totalAccounts = 2, ), @@ -211,13 +195,13 @@ class AccountListTest { PlusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toAdd = newAccount, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(newAccount), totalAccounts = 1, ), @@ -226,7 +210,7 @@ class AccountListTest { // endregion PlusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 20), totalAccounts = 20, ).getOrNull()!!, @@ -246,8 +230,6 @@ class AccountListTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Minus { - private val userWallet = mockk() - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: MinusTestModel) { @@ -266,13 +248,13 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount, secondaryAccount), totalAccounts = 2, ).getOrNull()!!, toRemove = secondaryAccount, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ), @@ -286,13 +268,13 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toRemove = notInList, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ), @@ -305,7 +287,7 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, @@ -321,7 +303,7 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount, secondaryAccount), totalAccounts = 2, ).getOrNull()!!, 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 4808e416a1..32e113b374 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 @@ -14,7 +14,6 @@ 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.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest @@ -35,20 +34,16 @@ class AddCryptoPortfolioUseCaseTest { mainAccountTokensMigration = mainAccountTokensMigration, ) - private val userWallet = mockk() - @BeforeEach fun resetMocks() { - clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration, userWallet) - - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration) } @Test fun `invoke should add new crypto portfolio account to existing list`() = runTest { // Arrange val newAccount = createNewAccount() - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! coEvery { @@ -152,7 +147,7 @@ class AddCryptoPortfolioUseCaseTest { fun `invoke should return error if account list requirements not met`() = runTest { // Arrange val accountList = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 20), totalAccounts = 20, ).getOrNull()!! @@ -228,7 +223,7 @@ class AddCryptoPortfolioUseCaseTest { fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange val newAccount = createNewAccount() - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! val exception = IllegalStateException("Test error") @@ -266,7 +261,7 @@ class AddCryptoPortfolioUseCaseTest { fun `invoke should return new account if migrate returns error`() = runTest { // Arrange val newAccount = createNewAccount() - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! val exception = Exception("Migration error") 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 fe67edc618..b23bd9bfe0 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 @@ -11,7 +11,6 @@ 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.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest @@ -24,19 +23,17 @@ class ArchiveCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) private val useCase = ArchiveCryptoPortfolioUseCase(crudRepository) - private val userWallet = mockk() @BeforeEach fun resetMocks() { - clearMocks(crudRepository, userWallet) - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository) } @Test fun `invoke should archive existing crypto portfolio account`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = (AccountList.empty(userWallet) + account).getOrNull()!! + val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! val accountId = account.accountId val updatedAccountList = (accountList - account).getOrNull()!! @@ -103,7 +100,7 @@ class ArchiveCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if account not found`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val accountId = AccountId.forCryptoPortfolio( userWalletId = userWalletId, derivationIndex = DerivationIndex(1).getOrNull()!!, @@ -126,7 +123,7 @@ class ArchiveCryptoPortfolioUseCaseTest { fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = (AccountList.empty(userWallet) + account).getOrNull()!! + val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! val accountId = account.accountId val updatedAccountList = (accountList - account).getOrNull()!! diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt index eb4e423c11..8a16fd034c 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt @@ -8,11 +8,11 @@ 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.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.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest @@ -27,20 +27,22 @@ import org.junit.jupiter.api.TestInstance class RecoverCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) - private val useCase = RecoverCryptoPortfolioUseCase(crudRepository) - private val userWallet = mockk() + private val mainAccountTokensMigration: MainAccountTokensMigration = mockk() + private val useCase = RecoverCryptoPortfolioUseCase( + crudRepository = crudRepository, + mainAccountTokensMigration = mainAccountTokensMigration, + ) @BeforeEach fun resetMocks() { - clearMocks(crudRepository, userWallet) - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository) } @Test fun `invoke should recover archived crypto portfolio account`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val archivedAccount = ArchivedAccount( accountId = account.accountId, name = account.accountName, @@ -54,6 +56,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() // Act val actual = useCase(account.accountId) @@ -62,7 +65,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = account.right() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) @@ -80,13 +83,14 @@ class RecoverCryptoPortfolioUseCaseTest { coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act - val actual = useCase(accountId) + val actual = useCase(accountId).leftOrNull() as Error.DataOperationFailed // Assert - val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() - Truth.assertThat(actual).isEqualTo(expected) + val expected = IllegalStateException("Account list not found for wallet $userWalletId") + Truth.assertThat(actual.cause).isInstanceOf(expected::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message) - coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } + coVerifySequence { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) @@ -111,7 +115,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } + coVerifySequence { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) @@ -122,7 +126,7 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if getArchivedAccount throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val exception = IllegalStateException("Test error") coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() @@ -135,7 +139,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) } @@ -146,19 +150,20 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if getArchivedAccount returns null`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None // Act - val actual = useCase(account.accountId) + val actual = useCase(account.accountId).leftOrNull() as Error.DataOperationFailed // Assert - val expected = Error.CriticalTechError.AccountNotFound(account.accountId).left() - Truth.assertThat(actual).isEqualTo(expected) + val expected = IllegalStateException("Account not found: ${account.accountId}") + Truth.assertThat(actual.cause).isInstanceOf(expected::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) } @@ -169,7 +174,7 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val archivedAccount = ArchivedAccount( accountId = account.accountId, name = account.accountName, @@ -193,7 +198,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt index 3d904d3117..724429e3d3 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt @@ -12,7 +12,6 @@ import com.tangem.domain.models.account.AccountId 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.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest @@ -29,19 +28,15 @@ class UpdateCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) private val useCase = UpdateCryptoPortfolioUseCase(crudRepository = crudRepository) - private val userWallet = mockk() - @BeforeEach fun resetMocks() { - clearMocks(crudRepository, userWallet) - - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository) } @Test fun `invoke should update crypto portfolio account with new name`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! @@ -66,7 +61,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke should update crypto portfolio account with new icon`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountIcon = CryptoPortfolioIcon.ofCustomAccount( @@ -94,7 +89,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke should update crypto portfolio account with new name and icon`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! @@ -123,7 +118,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if name and icon are null`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() @@ -144,7 +139,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if getAccounts throws exception`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! @@ -192,7 +187,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if getAccounts does not contain accountId`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = AccountId.forCryptoPortfolio( userWalletId = userWalletId, derivationIndex = DerivationIndex(1).getOrNull()!!, @@ -217,7 +212,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if saveAccounts throws exception`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 1bc12d67f3..8e2f490e75 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -24,13 +24,17 @@ dependencies { api(projects.domain.networks) api(projects.domain.staking) api(projects.domain.tokens) + api(projects.domain.wallets) + implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) implementation(deps.kotlin.datetime) implementation(deps.kotlin.serialization) implementation(deps.timber) + implementation(tangemDeps.blockchain) + // region DI implementation(deps.hilt.android) kapt(deps.hilt.kapt) 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 2e1d0ce365..79d373768b 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,10 +1,22 @@ package com.tangem.domain.account.status.di +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +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 +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -31,7 +43,39 @@ internal object AccountStatusUseCaseModule { @Provides @Singleton - fun provideGetAccountCurrencyStatusUseCase(): GetAccountCurrencyStatusUseCase { - return GetAccountCurrencyStatusUseCase() + fun provideGetAccountCurrencyStatusUseCase( + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + ): GetAccountCurrencyStatusUseCase { + return GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = singleAccountStatusListSupplier) + } + + @Provides + @Singleton + fun provideSaveCryptoCurrenciesUseCase( + singleAccountListSupplier: SingleAccountListSupplier, + accountsCRUDRepository: AccountsCRUDRepository, + currenciesRepository: CurrenciesRepository, + derivationsRepository: DerivationsRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + stakingIdFactory: StakingIdFactory, + networksCleaner: NetworksCleaner, + stakingCleaner: StakingCleaner, + dispatchers: CoroutineDispatcherProvider, + ): SaveCryptoCurrenciesUseCase { + return SaveCryptoCurrenciesUseCase( + singleAccountListSupplier = singleAccountListSupplier, + accountsCRUDRepository = accountsCRUDRepository, + currenciesRepository = currenciesRepository, + derivationsRepository = derivationsRepository, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteStatusFetcher = multiQuoteStatusFetcher, + multiYieldBalanceFetcher = multiYieldBalanceFetcher, + stakingIdFactory = stakingIdFactory, + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + dispatchers = dispatchers, + ) } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index 9cc97e0760..e8c9a00296 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 @@ -6,6 +6,7 @@ import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.producer.SingleAccountListProducer 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 @@ -39,6 +40,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 singleAccountListSupplier: SingleAccountListSupplier, private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory, private val dispatchers: CoroutineDispatcherProvider, @@ -58,8 +60,12 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo if (account.cryptoCurrencies.isEmpty()) { createEmptyAccountStatusFlow(account) } else { + val userWallet = userWalletsListRepository.userWalletsSync().first { + it.walletId == params.userWalletId + } + getAccountStatusFlow( - userWallet = accountList.userWallet, + userWallet = userWallet, account = account, groupType = accountList.groupType, sortType = accountList.sortType, @@ -71,7 +77,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo val balances = accountStatuses.map { it.tokenList.totalFiatBalance } AccountStatusList( - userWallet = accountList.userWallet, + userWalletId = accountList.userWalletId, accountStatuses = accountStatuses.toSet(), totalAccounts = accountList.totalAccounts, totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt index 5e5ef8f506..6177e2cde5 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt @@ -3,6 +3,8 @@ package com.tangem.domain.account.status.supplier import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow /** * Supplier that provides a single [AccountStatusList] for a specific user wallet. @@ -12,4 +14,10 @@ import com.tangem.domain.core.flow.FlowCachingSupplier abstract class SingleAccountStatusListSupplier( override val factory: SingleAccountStatusListProducer.Factory, override val keyCreator: (SingleAccountStatusListProducer.Params) -> String, -) : FlowCachingSupplier() \ No newline at end of file +) : FlowCachingSupplier() { + + operator fun invoke(userWalletId: UserWalletId): Flow { + val params = SingleAccountStatusListProducer.Params(userWalletId) + return this.invoke(params) + } +} \ 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 f038816b91..18abf00e6c 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 @@ -117,7 +117,7 @@ class GetAccountCurrencyByAddressUseCase( .firstOrNull() return ensureNotNull(result) { - "No account found for network: $networkId in walletId: ${accountList.userWallet.walletId}" + "No account found for network: $networkId in walletId: ${accountList.userWalletId}" } } 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 be08772f57..51abc977a6 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,24 +2,149 @@ package com.tangem.domain.account.status.usecase import arrow.core.Option import arrow.core.none +import arrow.core.toOption +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.producer.SingleAccountStatusListProducer +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.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull /** * Use case to retrieve the status of a specific cryptocurrency associated with an account. * + * @property singleAccountStatusListSupplier supplier to get the list of account statuses. + * [REDACTED_AUTHOR] */ -// TODO: Implement [REDACTED_JIRA] -class GetAccountCurrencyStatusUseCase { +class GetAccountCurrencyStatusUseCase( + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, +) { /** - * Invokes the use case to get the [AccountCryptoCurrencyStatus] for the given [currencyId]. + * Invokes the use case to get the status of a specific cryptocurrency for a given user wallet. * - * @param currencyId The ID of the cryptocurrency to look up. - * - * @return An [Option] containing the [AccountCryptoCurrencyStatus] if found, - * or [arrow.core.None] if not found or if any validation fails. + * @param userWalletId the ID of the user wallet. + * @param currency the cryptocurrency for which the status is to be retrieved. + * @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None. */ - suspend operator fun invoke(currencyId: CryptoCurrency.ID): Option = none() + suspend fun invokeSync(userWalletId: UserWalletId, currency: CryptoCurrency): Option { + return invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) + } + + /** + * Invokes the use case to get the status of a specific cryptocurrency by its ID for a given user wallet and network. + * If the [network] is null, it searches across all accounts for the cryptocurrency. + * + * @param userWalletId the ID of the user wallet. + * @param currencyId the ID of the cryptocurrency. + * @param network the network associated with the cryptocurrency, can be null. + * @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None. + */ + suspend fun invokeSync( + userWalletId: UserWalletId, + currencyId: CryptoCurrency.ID, + network: Network?, + ): Option { + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull( + params = SingleAccountStatusListProducer.Params(userWalletId), + ) ?: return none() + + return accountStatusList + .toAccountCryptoCurrencyStatus(currencyId, network) + .toOption() + } + + /** + * Retrieves the status of a specific cryptocurrency for a given user wallet as a [Flow]. + * + * @param userWalletId The ID of the user wallet. + * @param currency The cryptocurrency for which the status is to be retrieved. + * @return A [Flow] emitting [AccountCryptoCurrencyStatus] if found. + */ + operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Flow { + return invoke(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) + } + + /** + * Retrieves the status of a specific cryptocurrency by its ID for a given user wallet and network as a [Flow]. + * + * @param userWalletId The ID of the user wallet. + * @param currencyId The ID of the cryptocurrency. + * @param network The network associated with the cryptocurrency, can be null. + * @return A [Flow] emitting [AccountCryptoCurrencyStatus] if found. + */ + operator fun invoke( + userWalletId: UserWalletId, + currencyId: CryptoCurrency.ID, + network: Network?, + ): Flow { + return singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId), + ) + .mapNotNull { accountStatusList -> + accountStatusList.toAccountCryptoCurrencyStatus(currencyId, network) + } + } + + private fun AccountStatusList.toAccountCryptoCurrencyStatus( + currencyId: CryptoCurrency.ID, + network: Network?, + ): AccountCryptoCurrencyStatus? { + return getExpectedAccountStatuses(network) + .asSequence() + .filterIsInstance() + .mapNotNull { accountStatus -> + val status = accountStatus.flattenCurrencies().firstOrNull { it.currency.id == currencyId } + ?: return@mapNotNull null + + AccountCryptoCurrencyStatus(account = accountStatus.account, status = status) + } + .firstOrNull() + } + + /** + * 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. + * + * @param network the network to filter accounts by, can be null. + * @return a set of [AccountStatus] that match the expected criteria. + */ + private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): Set { + val possibleAccountIndex = network?.getAccountIndexOrNull() + + return when (possibleAccountIndex) { + // currency can be in any account + null -> accountStatuses + // currency only in the main account + DerivationIndex.Main.value -> setOf(mainAccount) + // currency only in the account with specific derivation index or in the main account + else -> { + val accountStatus = accountStatuses.firstOrNull { + val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@firstOrNull false + + cryptoPortfolio.derivationIndex.value == possibleAccountIndex + } + + setOfNotNull(accountStatus, mainAccount) + } + } + } + + private fun Network.getAccountIndexOrNull(): Int? { + val blockchain = Blockchain.fromNetworkId(networkId = rawId) ?: return null + val recognizer = AccountNodeRecognizer(blockchain) + + return recognizer.recognize(derivationPath)?.toInt() + } } \ 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/SaveCryptoCurrenciesUseCase.kt new file mode 100644 index 0000000000..b89a3f03f5 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt @@ -0,0 +1,269 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.core.utils.eitherOn +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 +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.* +import timber.log.Timber + +/** + * Use case for saving crypto currencies to a specific account. + * + * @property singleAccountListSupplier Supplier to get account details. + * @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 stakingIdFactory Factory for creating staking IDs. + * @property networksCleaner Cleaner for removing obsolete network data. + * @property stakingCleaner Cleaner for removing obsolete staking data. + * @property dispatchers Coroutine dispatchers for managing threading. + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +class SaveCryptoCurrenciesUseCase( + 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 stakingIdFactory: StakingIdFactory, + private val networksCleaner: NetworksCleaner, + private val stakingCleaner: StakingCleaner, + private val dispatchers: CoroutineDispatcherProvider, +) { + + suspend operator fun invoke( + accountId: AccountId, + add: List, + remove: List, + ): Either = eitherOn(dispatchers.default) { + if (add.isEmpty() && remove.isEmpty()) { + Timber.d("No currencies to add or remove, skipping") + return@eitherOn + } + + val userWalletId = accountId.userWalletId + withContext(NonCancellable) { + val account = getAccount(accountId = accountId) + + val modifiedCurrencyList = account.cryptoCurrencies.modify(add = add, remove = remove) + + saveAccount( + account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()), + ) + + derivePublicKeys(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + + val jobs = refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + + clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed) + + jobs.joinAll() + } + } + + private suspend fun Raise.getAccount(accountId: AccountId): Account.CryptoPortfolio { + val accountList = singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = accountId.userWalletId), + ) ?: raise(IllegalStateException("No accounts for wallet ${accountId.userWalletId}")) + + return accountList.accounts.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio + ?: raise(IllegalStateException("No account with id $accountId")) + } + + private fun Set.modify( + add: List, + remove: List, + ): ModifiedCurrencyList { + val mutableCurrencies = this.toMutableList() + val added = mutableListOf() + val removed = mutableListOf() + + val existingCurrenciesById = mutableCurrencies.associateBy(::TempID) + + add.groupByNetwork { !existingCurrenciesById.containsKey(it) } + .forEach { (network, currenciesById) -> + val coinTempId = TempID(network) + + if (!existingCurrenciesById.containsKey(coinTempId)) { + val coin = currenciesById[coinTempId] + + if (coin != null) { + mutableCurrencies.add(coin) + added.add(coin) + + currenciesById.remove(coinTempId) + } else { + val createdCoin = currenciesRepository.createCoinCurrency(network) + mutableCurrencies.add(createdCoin) + added.add(createdCoin) + } + } + + mutableCurrencies.addAll(currenciesById.values) + added.addAll(currenciesById.values) + } + + remove.groupByNetwork(valuePredicate = existingCurrenciesById::containsKey) + .forEach { (network, currenciesById) -> + val coinTempId = TempID(network) + + if (currenciesById.containsKey(coinTempId)) { + val existingNetworkCurrenciesCount = mutableCurrencies.count { it.network == network } + + if (existingNetworkCurrenciesCount != currenciesById.size) { + return@forEach + } + } + + mutableCurrencies.removeAll(currenciesById.values) + removed.addAll(currenciesById.values) + } + + return ModifiedCurrencyList(added = added, removed = removed, total = mutableCurrencies) + } + + private suspend fun Raise.saveAccount(account: Account.CryptoPortfolio) { + catch( + block = { accountsCRUDRepository.saveAccount(account) }, + catch = ::raise, + ) + } + + private suspend fun Raise.derivePublicKeys( + userWalletId: UserWalletId, + currencies: List, + ) { + catch( + block = { derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) }, + catch = ::raise, + ) + } + + private fun List.groupByNetwork( + valuePredicate: (TempID) -> Boolean, + ): LinkedHashMap> { + val destination = LinkedHashMap>() + + for (currency in this) { + val key = currency.network + val mutableMap = destination.getOrPut(key) { mutableMapOf() } + + val id = TempID(currency) + + if (valuePredicate(id)) { + mutableMap.put(id, currency) + } + } + + return destination + } + + private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List): List { + if (currencies.isEmpty()) return emptyList() + + return coroutineScope { + listOf( + launch { refreshNetworks(userWalletId = userWalletId, currencies = currencies) }, + launch { refreshYieldBalances(userWalletId = userWalletId, currencies = currencies) }, + launch { refreshQuotes(currencies = currencies) }, + ) + } + } + + private suspend fun refreshNetworks(userWalletId: UserWalletId, currencies: List) { + multiNetworkStatusFetcher( + params = MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = currencies.mapTo(hashSetOf(), CryptoCurrency::network), + ), + ) + + currenciesRepository.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 { + listOf( + launch { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, + launch { clearStaking(userWalletId = userWalletId, currencies = currencies) }, + ) + } + } + + private suspend fun clearStaking(userWalletId: UserWalletId, currencies: List) { + val stakingIds = currencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() + } + + stakingCleaner(userWalletId = userWalletId, stakingIds = stakingIds) + } + + private data class TempID( + val networkId: String, + val derivationPath: Network.DerivationPath, + val contractAddress: String?, + ) { + + constructor(network: Network) : this( + networkId = network.backendId, + derivationPath = network.derivationPath, + contractAddress = null, + ) + + constructor(currency: CryptoCurrency) : this( + networkId = currency.network.backendId, + derivationPath = currency.network.derivationPath, + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, + ) + } + + private data class ModifiedCurrencyList( + val added: List, + val removed: List, + val total: List, + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..a3becc8abf --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt @@ -0,0 +1,198 @@ +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.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultMultiAccountStatusListProducerTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val userWalletId1 = UserWalletId("001") + private val userWallet1 = mockk { + every { walletId } returns userWalletId1 + } + + private val userWalletId2 = UserWalletId("002") + private val userWallet2 = mockk { + every { walletId } returns userWalletId2 + } + + private val producer = DefaultMultiAccountStatusListProducer( + params = Unit, + userWalletsListRepository = userWalletsListRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + dispatchers = dispatchers, + ) + + @AfterEach + fun tearDown() { + clearMocks(userWalletsListRepository, singleAccountStatusListSupplier) + } + + @Test + fun `produce returns status lists for all user wallets`() = runTest { + // Arrange + val wallets = listOf(userWallet1, userWallet2) + val walletsFlow = MutableStateFlow(wallets) + + every { userWalletsListRepository.userWallets } returns walletsFlow + val accountStatusList1 = mockk() + val accountStatusList2 = mockk() + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + } returns flowOf(accountStatusList1) + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + } returns flowOf(accountStatusList2) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = listOf(accountStatusList1, accountStatusList2) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsListRepository.userWallets + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + } + } + + @Test + fun `produce returns empty flow if userWallets is empty list`() = runTest { + // Arrange + val walletsFlow = MutableStateFlow>(emptyList()) + every { userWalletsListRepository.userWallets } returns walletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsListRepository.userWallets + } + } + + @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 + } + } + + @Test + fun `flow will updated if userWallets are updated`() = runTest { + // Arrange + val userWalletId3 = UserWalletId("003") + val userWallet3 = mockk { every { walletId } returns userWalletId3 } + + val walletsFlow = MutableStateFlow(listOf(userWallet1, userWallet2)) + every { userWalletsListRepository.userWallets } returns walletsFlow + + val accountStatusList1 = mockk() + val accountStatusList2 = mockk() + val accountStatusList3 = mockk() + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + } returns flowOf(accountStatusList1) + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + } returns flowOf(accountStatusList2) + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId3), + ) + } returns flowOf(accountStatusList3) + + // Act (first emission) + val actual1 = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + val expected1 = listOf(accountStatusList1, accountStatusList2) + Truth.assertThat(actual1).containsExactly(expected1) + + // Act (second emission) + walletsFlow.value = listOf(userWallet1, userWallet2, userWallet3) + val actual2 = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + val expected2 = listOf(accountStatusList1, accountStatusList2, accountStatusList3) + Truth.assertThat(actual2).containsExactly(expected2) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsListRepository.userWallets + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId3), + ) + userWalletsListRepository.userWallets + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId3), + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6601e4c64e --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt @@ -0,0 +1,249 @@ +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.status.utils.CryptoCurrencyStatusesFlowFactory +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.core.utils.lceContent +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSingleAccountStatusListProducerTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val singleAccountListSupplier: SingleAccountListSupplier = mockk() + private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + private val producer = DefaultSingleAccountStatusListProducer( + params = SingleAccountStatusListProducer.Params(userWalletId), + userWalletsListRepository = userWalletsListRepository, + singleAccountListSupplier = singleAccountListSupplier, + cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDown() { + clearMocks(singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory) + } + + @Test + fun `flow is mapped for user wallet id from params`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId = userWalletId) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } + } + + @Test + fun `flow will updated if balances are updated`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId) + val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.BALANCE) + + val accountListFlow = MutableStateFlow(value = accountList) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns accountListFlow + + // Act (first emission) + val actual1 = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + val expected = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + Truth.assertThat(actual1).containsExactly(expected) + + // Act (second emission) + accountListFlow.value = updatedAccountList + val actual2 = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + val expected2 = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = updatedAccountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + Truth.assertThat(actual2).containsExactly(expected2) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } + } + + @Test + fun `flow is filtered the same balance`() = runTest { + // Arrange + val accountList = AccountList.empty(userWalletId) + val accountListFlow = MutableStateFlow(value = accountList) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns accountListFlow + + val expected = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + + // Act (first emission) + val actual1 = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(actual1).containsExactly(expected) + + // Act (second emission) + accountListFlow.value = accountList + val actual2 = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(actual2).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } + } + + @Test + fun `flow is produced for account with non empty crypto currencies`() = runTest { + // Arrange + val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + val accountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = cryptoCurrencyFactory.ethereumAndStellar.toSet(), + ) + + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns flowOf(accountList) + + val ethereumStatus = CryptoCurrencyStatus( + currency = cryptoCurrencyFactory.ethereum, + value = CryptoCurrencyStatus.Loading, + ) + every { + cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.ethereum) + } returns flowOf(ethereumStatus) + + val stellarStatus = CryptoCurrencyStatus( + currency = cryptoCurrencyFactory.stellar, + value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null), + ) + every { + cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.stellar) + } returns flowOf(stellarStatus) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = nonEmptyListOf(ethereumStatus, stellarStatus), + ), + priceChangeLce = lceLoading(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loading, + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(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 829ce7b3ec..34b63f7842 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 @@ -218,7 +218,7 @@ class GetAccountCurrencyByAddressUseCaseTest { }, value = NetworkStatus.Unreachable(address = validNetworkAddress), ) - val accountList = AccountList.empty(multiUserWallet) + val accountList = AccountList.empty(userWalletId) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) coEvery { @@ -253,7 +253,7 @@ class GetAccountCurrencyByAddressUseCaseTest { network = currency.network, value = NetworkStatus.Unreachable(address = validNetworkAddress), ) - val accountList = AccountList.empty(userWallet = multiUserWallet, cryptoCurrencies = setOf(currency)) + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = setOf(currency)) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) coEvery { 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 new file mode 100644 index 0000000000..7adf3a3264 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt @@ -0,0 +1,289 @@ +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 +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.core.utils.lceLoading +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.account.DerivationIndex +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 io.mockk.* +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetAccountCurrencyStatusUseCaseTest { + + private val supplier = mockk() + private val useCase = GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = supplier) + + private val userWalletId = UserWalletId("011") + private val supplierParams = SingleAccountStatusListProducer.Params(userWalletId) + private val currency = MockCryptoCurrencyFactory().ethereum.let { + val derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/1") + + it.copy( + network = it.network.copy( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + derivationPath = derivationPath, + ), + ) + } + + @BeforeEach + fun setUp() { + clearMocks(supplier) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class InvokeSync { + + @Test + fun `invokeSync returns None when supplier returns null`() = runTest { + // Arrange + coEvery { supplier.getSyncOrNull(supplierParams) } returns null + + // Act + val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + assertNone(actual) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invokeSync returns None when AccountList does not contain required currency id`() = runTest { + // Arrange + val accountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + assertNone(actual) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invokeSync returns Some if network is not null`() = runTest { + // Arrange + val mainAccountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val account = mockk(relaxed = true) { + every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase.invokeSync( + userWalletId = userWalletId, + currencyId = currency.id, + network = currency.network, + ) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + assertSome(actual, expected) + + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invokeSync returns Some if network is null`() = runTest { + // Arrange + val account = mockk(relaxed = true) { + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + assertSome(actual, expected) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + } + + @Suppress("UnusedFlow") + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Invoke { + + @Test + fun `invoke returns empty flow when supplier returns empty flow`() = runTest { + // Arrange + coEvery { supplier(supplierParams) } returns emptyFlow() + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() + coVerifyOrder { supplier(supplierParams) } + } + + @Test + fun `invoke returns empty flow when AccountList does not contain required currency id`() = runTest { + // Arrange + val accountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() + coVerifyOrder { supplier(supplierParams) } + } + + @Test + fun `invoke returns data if network is not null`() = runTest { + // Arrange + val mainAccountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val account = mockk(relaxed = true) { + every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + } + + coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + Truth.assertThat(actual).containsExactly(expected) + + coVerifyOrder { supplier(supplierParams) } + } + + @Test + fun `invoke returns data if network is null`() = runTest { + // Arrange + val account = mockk(relaxed = true) { + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + Truth.assertThat(actual).containsExactly(expected) + coVerifyOrder { supplier(supplierParams) } + } + } +} \ 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 new file mode 100644 index 0000000000..0036579daf --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt @@ -0,0 +1,308 @@ +package com.tangem.domain.account.status.utils + +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 +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.staking.YieldBalance +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.networks.single.SingleNetworkStatusProducer +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +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 io.mockk.* +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoCurrencyStatusesFlowFactoryTest { + + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier = mockk() + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk() + private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + + private val factory = CryptoCurrencyStatusesFlowFactory( + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + singleQuoteStatusSupplier = singleQuoteStatusSupplier, + singleYieldBalanceSupplier = singleYieldBalanceSupplier, + stakingIdFactory = stakingIdFactory, + ) + + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + private val networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address(value = "0x1", type = NetworkAddress.Address.Type.Primary), + ) + + @AfterEach + fun tearDown() { + clearMocks( + singleNetworkStatusSupplier, + singleQuoteStatusSupplier, + singleYieldBalanceSupplier, + stakingIdFactory, + ) + } + + @Test + fun `if rawCurrencyId is null, there will be no subscription to the quote status`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val currency = cryptoCurrencyFactory.ethereum.copy( + id = cryptoCurrencyFactory.ethereum.id.copy( + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"), + ), + ) + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + val stakingId = StakingID(integrationId = "id", address = networkAddress.defaultAddress.value) + coEvery { + stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value) + } returns stakingId.right() + + val yieldBalance = YieldBalance.Empty(stakingId = stakingId, source = StatusSource.ACTUAL) + val yieldBalanceFlow = flowOf(yieldBalance) + every { + singleYieldBalanceSupplier( + params = SingleYieldBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId), + ) + } returns yieldBalanceFlow + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value) + singleYieldBalanceSupplier(params = SingleYieldBalanceProducer.Params(userWalletId, stakingId)) + } + } + + @Test + fun `if userWallet is not multi-currency, there will be no subscription to the yield balance`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns false + } + + val currency = cryptoCurrencyFactory.ethereum + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + val quoteStatus = QuoteStatus( + rawCurrencyId = currency.id.rawCurrencyId!!, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ONE, + ), + ) + val quoteStatusFlow = flowOf(quoteStatus) + every { + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } returns quoteStatusFlow + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = BigDecimal.ONE, + fiatRate = BigDecimal.ONE, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } + } + + @Test + fun `no subscription to the quote status and yield balance`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns false + } + + val currency = cryptoCurrencyFactory.ethereum.copy( + id = cryptoCurrencyFactory.ethereum.id.copy( + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"), + ), + ) + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } + } + + @Test + fun `if stakingId is not supported, yield balance will be null`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val currency = cryptoCurrencyFactory.ethereum + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + val quoteStatus = QuoteStatus( + rawCurrencyId = currency.id.rawCurrencyId!!, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ONE, + ), + ) + val quoteStatusFlow = flowOf(quoteStatus) + every { + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } returns quoteStatusFlow + + coEvery { + stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value) + } returns StakingIdFactory.Error.UnsupportedCurrency.left() + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = BigDecimal.ONE, + fiatRate = BigDecimal.ONE, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } + } + + @Test + fun `all sources are empty`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val currency = cryptoCurrencyFactory.ethereum + + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns emptyFlow() + + every { + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } returns emptyFlow() + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } + } +} \ No newline at end of file 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 0d983f457f..aa67bf21fe 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt @@ -212,6 +212,9 @@ data object Wallet2CardConfig : CardConfig { Blockchain.HyperliquidTestnet -> EllipticCurve.Secp256k1 Blockchain.Quai -> EllipticCurve.Secp256k1 Blockchain.QuaiTestnet -> EllipticCurve.Secp256k1 + Blockchain.Linea -> EllipticCurve.Secp256k1 + Blockchain.LineaTestnet -> EllipticCurve.Secp256k1 + Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1 } } } \ 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 8f9f3f26ff..d211d4c8c9 100644 --- a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt +++ b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt @@ -168,6 +168,9 @@ class Wallet2CardConfigTest { Blockchain.HyperliquidTestnet to EllipticCurve.Secp256k1, Blockchain.Quai to EllipticCurve.Secp256k1, Blockchain.QuaiTestnet to EllipticCurve.Secp256k1, + Blockchain.Linea to EllipticCurve.Secp256k1, + Blockchain.LineaTestnet to EllipticCurve.Secp256k1, + Blockchain.ArbitrumNova to EllipticCurve.Secp256k1, ) @Test diff --git a/domain/legacy/src/main/java/com/tangem/domain/tokens/TokensAction.kt b/domain/legacy/src/main/java/com/tangem/domain/tokens/TokensAction.kt deleted file mode 100644 index 90378e19c2..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/tokens/TokensAction.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.domain.tokens - -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Token -import org.rekotlin.Action - -sealed interface TokensAction : Action { - - /** Single way to pass data to the screen */ - sealed interface SetArgs : TokensAction { - object ManageAccess : SetArgs - object ReadAccess : SetArgs - } -} - -data class TokenWithBlockchain(val token: Token, val blockchain: Blockchain) \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt index 97a9008aa1..5354432e4c 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt @@ -1,5 +1,6 @@ package com.tangem.domain.markets +import kotlinx.serialization.Serializable import org.joda.time.DateTime import java.math.BigDecimal @@ -18,6 +19,8 @@ data class TokenMarketInfo( val pricePerformance: PricePerformance?, val exchangesAmount: Int?, ) { + + @Serializable data class Network( val networkId: String, val exchangeable: Boolean, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt index d3962fbd50..4b0f60f75a 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt @@ -57,13 +57,17 @@ sealed interface Account { val networksCount: Int get() = cryptoCurrencies.map(CryptoCurrency::network).distinct().size - fun copy(accountName: AccountName = this.accountName, icon: CryptoPortfolioIcon = this.icon): CryptoPortfolio { + fun copy( + accountName: AccountName = this.accountName, + icon: CryptoPortfolioIcon = this.icon, + cryptoCurrencies: Set = this.cryptoCurrencies, + ): CryptoPortfolio { return CryptoPortfolio( accountId = this.accountId, accountName = accountName, icon = icon, derivationIndex = this.derivationIndex, - cryptoCurrencies = this.cryptoCurrencies, + cryptoCurrencies = cryptoCurrencies, ) } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt new file mode 100644 index 0000000000..b3fb460bdb --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.models.currency + +fun CryptoCurrency.Token.yieldSupplyKey(): String { + return "${network.backendId}_$contractAddress" +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt new file mode 100644 index 0000000000..5db343c0c4 --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.networks.utils + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Cleans up network-related data for a specific user wallet and a list of cryptocurrencies. + * +[REDACTED_AUTHOR] + */ +interface NetworksCleaner { + + /** + * Cleans up network-related data for the given [userWalletId] and list of [currencies]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currencies The list of cryptocurrencies whose associated network data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currencies: List) +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt new file mode 100644 index 0000000000..84925b509b --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.staking.utils + +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Cleans up staking-related data for a specific user wallet and a set of staking IDs. + * +[REDACTED_AUTHOR] + */ +interface StakingCleaner { + + /** + * Cleans up staking-related data for the given [userWalletId] and set of [stakingIds]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param stakingIds The set of staking IDs whose associated data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, stakingIds: Set) +} \ 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 6dd77dfdc2..826fb686d5 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 @@ -40,26 +40,7 @@ interface CurrenciesRepository { * @param userWalletId The unique identifier of the user wallet. * @param currencies The list of cryptocurrencies to be saved. */ - suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List) - - /** - * Add currencies to a specific user wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @param currencies The currencies which must be added. - * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet - * ID provided. - */ - suspend fun addCurrencies(userWalletId: UserWalletId, currencies: List): List - - /** - * 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. - */ - @Deprecated("Tech debt") - suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List) + suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) /** * Add currencies to a specific user wallet. @@ -256,6 +237,8 @@ interface CurrenciesRepository { */ suspend fun getFeePaidCurrency(userWalletId: UserWalletId, network: Network): FeePaidCurrency + fun createCoinCurrency(network: Network): CryptoCurrency.Coin + /** * Creates token [cryptoCurrency] based on current token and [network] it`s will be added */ 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 8a0f5fa136..c4459c749e 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,14 +46,7 @@ internal class MockCurrenciesRepository( isTokensSortedByBalanceAfterSortingApply = isSortedByBalance } - override suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List) = Unit - - override suspend fun addCurrencies( - userWalletId: UserWalletId, - currencies: List, - ): List = emptyList() - - override suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List) = Unit + override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) = Unit override suspend fun addCurrenciesCache( userWalletId: UserWalletId, @@ -152,6 +145,10 @@ internal class MockCurrenciesRepository( return FeePaidCurrency.Coin } + override fun createCoinCurrency(network: Network): CryptoCurrency.Coin { + error("not implemented") + } + override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token { return cryptoCurrency } 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 65d960d01c..6793255df3 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 @@ -19,7 +19,7 @@ sealed class TangemPayTxHistoryItem { val merchantName: String, val enrichedMerchantCategory: String?, val merchantCategory: String, - val status: String, + val status: Status, val enrichedMerchantIconUrl: String?, ) : TangemPayTxHistoryItem() @@ -36,4 +36,12 @@ sealed class TangemPayTxHistoryItem { override val amount: BigDecimal, override val currency: Currency, ) : TangemPayTxHistoryItem() + + enum class Status { + PENDING, + RESERVED, + COMPLETED, + DECLINED, + UNKNOWN, + } } \ 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 9a5438f259..cb32317055 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 @@ -2,8 +2,6 @@ package com.tangem.domain.pay.model import java.math.BigDecimal -private const val APPROVED_KYC_STATUS = "APPROVED" - data class MainScreenCustomerInfo( val info: CustomerInfo, val orderStatus: OrderStatus, @@ -11,7 +9,7 @@ data class MainScreenCustomerInfo( data class CustomerInfo( val productInstance: ProductInstance?, - val kycStatus: String?, + val isKycApproved: Boolean, val cardInfo: CardInfo?, ) { @@ -26,6 +24,4 @@ data class CustomerInfo( val currencyCode: String, val customerWalletAddress: String, ) - - fun isKycApproved() = kycStatus == APPROVED_KYC_STATUS } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index 6cab68a6fd..6ae706a0e1 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -1,7 +1,7 @@ package com.tangem.domain.pay.model enum class OrderStatus(val apiName: String) { - NOT_ISSUED(""), + UNKNOWN(""), NEW("NEW"), PROCESSING("PROCESSING"), COMPLETED("COMPLETED"), diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt index c3f3df8d95..ce09a009a3 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt @@ -36,7 +36,8 @@ interface HotWalletPasswordRequester { * @param hotWalletId The ID of the hot wallet to authenticate with. * @param authMode Indicates whether the request is for authentication mode. * In auth mode user can be deleted after failed attempts. - * @param hasBiometry Indicates whether to show biometric authentication option. + * @param hasBiometry Indicates whether to show biometric authentication option to the user. + * Will be ignored if the device does not support biometry at the moment of the request. */ data class AttemptRequest( val hotWalletId: HotWalletId, diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt index 900bb790b2..7d379d4af6 100644 --- a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt +++ b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt @@ -12,6 +12,8 @@ data class YieldMarketToken( val chainId: Int, val apy: SerializedBigDecimal, val isActive: Boolean, + val maxFeeNative: String, + val maxFeeUSD: String, val backendId: String? = null, ) { diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketTokenStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketTokenStatus.kt deleted file mode 100644 index d7a6eedcf3..0000000000 --- a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketTokenStatus.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.domain.yield.supply.models - -import com.tangem.domain.models.serialization.SerializedBigDecimal -import kotlinx.serialization.Serializable - -/** - * Domain model representing a token entry in the Yield Markets list. - */ -@Serializable -data class YieldMarketTokenStatus( - val tokenAddress: String, - val tokenSymbol: String, - val tokenName: String, - val chainId: Int, - val apy: SerializedBigDecimal, - val isActive: Boolean, - val maxFeeNative: String, - val maxFeeUSD: String, -) \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyMarketRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyMarketRepository.kt deleted file mode 100644 index 5d26c82073..0000000000 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyMarketRepository.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.domain.yield.supply - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus -import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData -import kotlinx.coroutines.flow.Flow - -interface YieldSupplyMarketRepository { - - /** - * Get cached yield markets or null if nothing cached yet. - */ - suspend fun getCachedMarkets(): List? - - /** - * Update markets by fetching from network and cache the result. Returns latest markets. - */ - @Throws - suspend fun updateMarkets(): List - - /** - * Observe runtime markets updates. - */ - fun getMarketsFlow(): Flow> - - /** - * Get yield token status by contract address. - */ - @Throws - suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus - - /** - * Get yield token APY chart by contract address. - */ - @Throws - suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData -} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt new file mode 100644 index 0000000000..c721f9e942 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt @@ -0,0 +1,59 @@ +package com.tangem.domain.yield.supply + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData +import kotlinx.coroutines.flow.Flow + +interface YieldSupplyRepository { + + /** + * Get cached yield markets or null if nothing cached yet. + */ + suspend fun getCachedMarkets(): List? + + /** + * Update markets by fetching from network and cache the result. Returns latest markets. + */ + @Throws + suspend fun updateMarkets(): List + + /** + * Observe runtime markets updates. + */ + fun getMarketsFlow(): Flow> + + /** + * Get yield token status by contract address from cache + */ + @Throws + suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketToken + + /** + * Get yield token APY chart by contract address. + */ + @Throws + suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData + + suspend fun isYieldSupplySupported(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean + + /** + * Activate yield protocol for the specified token. + * + * Returns whether the token is active after the operation completes. + * May throw on network/backend errors or if required chain id cannot be resolved. + */ + @Throws + suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean + + /** + * Deactivate yield protocol for the specified token. + * + * Returns whether the token is active after the operation completes + * (expected to be false when deactivation succeeds). May throw on + * network/backend errors or if required chain id cannot be resolved. + */ + @Throws + suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt new file mode 100644 index 0000000000..3ca5a7b0e8 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.yield.supply.YieldSupplyRepository + +class YieldSupplyActivateUseCase( + private val yieldSupplyRepository: YieldSupplyRepository, +) { + + suspend operator fun invoke(cryptoCurrency: CryptoCurrency): Either = Either.catch { + val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected") + yieldSupplyRepository.activateProtocol(token) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt index 9efcab1ea6..d93fcca1a1 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt @@ -1,6 +1,6 @@ package com.tangem.domain.yield.supply.usecase -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -12,11 +12,11 @@ import kotlinx.coroutines.flow.map * - value: APY as string */ class YieldSupplyApyFlowUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { operator fun invoke(): Flow> { - return yieldSupplyMarketRepository.getMarketsFlow() + return yieldSupplyRepository.getMarketsFlow() .map { yieldMarketTokenList -> yieldMarketTokenList.filter { it.isActive }.associate { token -> token.yieldSupplyKey to token.apy.toString() diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt index a3d9200e44..d2dff0ea7f 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt @@ -1,7 +1,7 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import kotlin.collections.filter /** @@ -12,11 +12,11 @@ import kotlin.collections.filter * - value: APY as string */ class YieldSupplyApyUpdateUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { suspend operator fun invoke(): Either> = Either.catch { - yieldSupplyMarketRepository.updateMarkets() + yieldSupplyRepository.updateMarkets() .filter { it.isActive } .associate { it.tokenAddress to it.apy.toString() diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt new file mode 100644 index 0000000000..fc666ada1a --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.yield.supply.YieldSupplyRepository + +class YieldSupplyDeactivateUseCase( + private val yieldSupplyRepository: YieldSupplyRepository, +) { + + suspend operator fun invoke(cryptoCurrency: CryptoCurrency): Either = Either.catch { + val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected") + yieldSupplyRepository.deactivateProtocol(token) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt index 6bb5070262..14f176b3ec 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt @@ -1,14 +1,14 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository class YieldSupplyGetApyUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { suspend operator fun invoke(tokenAddress: String): Either = Either.catch { - val apys = yieldSupplyMarketRepository.getCachedMarkets() ?: yieldSupplyMarketRepository.updateMarkets() + val apys = yieldSupplyRepository.getCachedMarkets() ?: yieldSupplyRepository.updateMarkets() apys.first { it.tokenAddress == tokenAddress }.apy.toString() } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt index 06cb773d1c..3d1142fb98 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt @@ -2,15 +2,15 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData class YieldSupplyGetChartUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { suspend operator fun invoke(cryptoCurrency: CryptoCurrency.Token): Either = Either.catch { - yieldSupplyMarketRepository.getTokenChart(cryptoCurrency) + yieldSupplyRepository.getTokenChart(cryptoCurrency) } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt index 3a6d689924..36737e8d43 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt @@ -2,14 +2,17 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus +import com.tangem.domain.models.currency.yieldSupplyKey +import com.tangem.domain.yield.supply.YieldSupplyRepository +import com.tangem.domain.yield.supply.models.YieldMarketToken class YieldSupplyGetTokenStatusUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { - suspend operator fun invoke(token: CryptoCurrency.Token): Either = Either.catch { - yieldSupplyMarketRepository.getTokenStatus(token) + suspend operator fun invoke(token: CryptoCurrency.Token): Either = Either.catch { + val tokens = yieldSupplyRepository.getCachedMarkets().orEmpty() + val cachedStatus = tokens.firstOrNull { it.yieldSupplyKey == token.yieldSupplyKey() } + cachedStatus ?: error("YieldMarketToken not found") } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyIsAvailableUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyIsAvailableUseCase.kt new file mode 100644 index 0000000000..4b7fc6e168 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyIsAvailableUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.yield.supply.usecase + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyRepository + +class YieldSupplyIsAvailableUseCase( + private val yieldSupplyRepository: YieldSupplyRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { + return yieldSupplyRepository.isYieldSupplySupported(userWalletId, cryptoCurrency) + } +} \ No newline at end of file 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 0dc73a77b7..22fae1a67e 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 @@ -28,7 +28,7 @@ interface PortfolioFetcher { val walletBalance: Lce, val accountsBalance: AccountStatusList, ) { - val userWallet: UserWallet get() = accountsBalance.userWallet + val userWalletId: UserWalletId get() = accountsBalance.userWalletId } sealed interface Mode { 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 f1c4734092..76c6acb6da 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,8 @@ 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 import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router @@ -11,6 +13,8 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage 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.usecase.GetArchivedAccountsUseCase @@ -20,6 +24,7 @@ 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.createedit.error.AccountFeatureError import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -37,6 +42,7 @@ internal class ArchivedAccountListModel @Inject constructor( private val recoverCryptoPortfolioUseCase: RecoverCryptoPortfolioUseCase, private val getArchivedAccountsUseCase: GetArchivedAccountsUseCase, private val umBuilder: AccountArchivedUMBuilder, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : Model() { private val params = paramsContainer.require() @@ -103,11 +109,37 @@ internal class ArchivedAccountListModel @Inject constructor( ) } - private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch { + private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch(dispatchers.default) { recoverCryptoPortfolioUseCase(accountId) - .onLeft { Timber.e(it.toString()) } - .onRight { showSuccessRecoverMessage() } - router.pop() + .onLeft(::handleRecoverError) + .onRight { + showSuccessRecoverMessage() + router.pop() + } + } + + private fun handleRecoverError(error: RecoverCryptoPortfolioUseCase.Error) { + 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 + return + } + + val featureError = AccountFeatureError.ArchivedAccountList.FailedToRecoverAccount(cause = error) + logError(error = featureError) + messageSender.showErrorDialog(universalError = featureError, onDismiss = router::pop) + } + + private fun logError(error: AccountFeatureError, params: Map = mapOf()) { + val exception = IllegalStateException(error.toString()) + + Timber.e(exception) + + analyticsExceptionHandler.sendException( + event = ExceptionAnalyticsEvent(exception = exception, params = params), + ) } private fun showSuccessRecoverMessage() { diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt index 2c0a24413a..2cf339e309 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt @@ -13,7 +13,10 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase +import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon import com.tangem.features.account.details.entity.AccountDetailsUM @@ -30,6 +33,7 @@ internal class AccountDetailsModel @Inject constructor( private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val archiveCryptoPortfolioUseCase: ArchiveCryptoPortfolioUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, ) : Model() { private val params = paramsContainer.require() @@ -42,8 +46,11 @@ internal class AccountDetailsModel @Inject constructor( } private fun onManageTokensClick() { - // todo account add account param - router.push(AppRoute.ManageTokens(source = AppRoute.ManageTokens.Source.SETTINGS)) + val route = AppRoute.ManageTokens( + source = AppRoute.ManageTokens.Source.SETTINGS, + portfolioId = PortfolioId(params.account.accountId), + ) + router.push(route) } private fun onArchiveAccountClick() { @@ -89,6 +96,8 @@ internal class AccountDetailsModel @Inject constructor( ) } } + val isMultiCurrency = getUserWalletUseCase(params.account.accountId.userWalletId) + .getOrNull()?.isMultiCurrency ?: false return AccountDetailsUM( accountName = params.account.accountName.toUM().value, accountIcon = params.account.portfolioIcon.toUM(), @@ -96,6 +105,7 @@ internal class AccountDetailsModel @Inject constructor( onAccountEditClick = ::onEditAccountClick, onManageTokensClick = ::onManageTokensClick, archiveMode = archiveMode, + isManageTokensAvailable = isMultiCurrency, ) } } \ 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 3938e757fb..27ad494553 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 @@ -7,6 +7,7 @@ internal data class AccountDetailsUM( val accountName: TextReference, val accountIcon: CryptoPortfolioIconUM, val archiveMode: ArchiveMode, + val isManageTokensAvailable: Boolean, val onCloseClick: () -> Unit, val onAccountEditClick: () -> Unit, val onManageTokensClick: () -> Unit, 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 bd4a0e319f..e3708f495f 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 @@ -22,7 +22,6 @@ import com.tangem.common.ui.R import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.common.ui.account.AccountRow import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -49,6 +48,7 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = ) Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxSize() .padding(horizontal = TangemTheme.dimens.spacing16) @@ -61,21 +61,22 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = style = TangemTheme.typography.h1, color = TangemTheme.colors.text.primary1, ) - SpacerH16() AccountRow(state) - SpacerH16() - ManageTokensRow(state) + if (state.isManageTokensAvailable) { + ManageTokensRow(state) + } when (state.archiveMode) { is AccountDetailsUM.ArchiveMode.Available -> { - SpacerH16() - ArchiveAccountRow(state.archiveMode) - SpacerH(8.dp) - Text( - modifier = Modifier.padding(horizontal = 12.dp), - text = stringResourceSafe(R.string.account_details_archive_description), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) + Column { + ArchiveAccountRow(state.archiveMode) + SpacerH(8.dp) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResourceSafe(R.string.account_details_archive_description), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } } AccountDetailsUM.ArchiveMode.None -> Unit } @@ -186,10 +187,12 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider field = MutableStateFlow( CreateWalletSelectionUM( onBackClick = { router.pop() }, - onMobileWalletClick = ::onMobileWalletClick, - onHardwareWalletClick = ::onHardwareWalletClick, - onScanClick = ::onScanClick, + blocks = persistentListOf( + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_hardware_title), + titleLabel = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), + description = resourceReference(R.string.wallet_add_hardware_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_add_wallet_16, + title = resourceReference(R.string.wallet_add_hardware_info_create), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = ::onHardwareWalletClick, + ), + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_mobile_title), + titleLabel = null, + description = resourceReference(R.string.wallet_add_mobile_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_mobile_wallet_16, + title = resourceReference(R.string.hw_create_title), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = ::onMobileWalletClick, + ), + ), + onBuyClick = ::onBuyClick, ), ) @@ -86,6 +94,10 @@ internal class CreateWalletSelectionModel @Inject constructor( } private fun onHardwareWalletClick() { + // TODO [REDACTED_TASK_KEY] + } + + private fun onBuyClick() { analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards) analyticsEventHandler.send(Shop.ScreenOpened) modelScope.launch { @@ -93,113 +105,6 @@ internal class CreateWalletSelectionModel @Inject constructor( } } - private fun onScanClick() { - analyticsEventHandler.send(IntroductionProcess.ButtonScanCard) - 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 { - appRouter.replaceAll(AppRoute.Wallet) - } - } - } - }, - ifRight = { - setLoading(false) - sendSignedInCardAnalyticsEvent(scanResponse) - appRouter.replaceAll(AppRoute.Wallet) - }, - ) - } - - private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { - 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(), - hasBackup = scanResponse.card.backupStatus?.isActive, - ), - ) - } - } - - private fun setLoading(isLoading: Boolean) { - uiState.update { it.copy(isScanInProgress = isLoading) } - } - - 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), - ), - ) - } - companion object { private const val SHOW_ALREADY_HAVE_WALLET_DELAY = 3000L } diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt index ef14600b7a..1d76d78bac 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt @@ -1,11 +1,26 @@ package com.tangem.features.createwalletselection.entity +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + internal data class CreateWalletSelectionUM( val isScanInProgress: Boolean = false, - val hardwareWalletPrice: String = "$54.90", val showAlreadyHaveWallet: Boolean = false, + val blocks: ImmutableList, val onBackClick: () -> Unit, - val onMobileWalletClick: () -> Unit, - val onHardwareWalletClick: () -> Unit, - val onScanClick: () -> Unit, -) \ No newline at end of file + val onBuyClick: () -> Unit, +) { + data class Block( + val title: TextReference, + val titleLabel: LabelUM?, + val description: TextReference, + val features: ImmutableList, + val onClick: () -> Unit, + ) + + data class Feature( + val iconResId: Int, + val title: TextReference, + ) +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt index 1df0b5eff7..1a953f008d 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt @@ -10,23 +10,25 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource 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.buttons.common.TangemButton -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.buttons.common.TangemButtonSize -import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults -import com.tangem.core.ui.extensions.conditional +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.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM import com.tangem.features.createwalletselection.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @@ -34,7 +36,7 @@ import com.tangem.features.createwalletselection.impl.R internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifier: Modifier = Modifier) { Column( modifier = modifier - .background(TangemTheme.colors.background.primary) + .background(TangemTheme.colors.background.secondary) .fillMaxSize() .systemBarsPadding(), ) { @@ -42,7 +44,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi modifier = Modifier .statusBarsPadding(), colors = TopAppBarDefaults.topAppBarColors( - containerColor = TangemTheme.colors.background.primary, + containerColor = TangemTheme.colors.background.secondary, ), navigationIcon = { IconButton(onClick = state.onBackClick) { @@ -58,7 +60,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi Text( modifier = Modifier .padding(16.dp), - text = stringResourceSafe(R.string.wallet_create_nav_info_title), + text = stringResourceSafe(R.string.wallet_add_support_title), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, maxLines = 1, @@ -78,60 +80,33 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - text = stringResourceSafe(R.string.wallet_create_title), + .padding( + start = 16.dp, + end = 16.dp, + bottom = 24.dp, + ), + text = stringResourceSafe(R.string.wallet_add_common_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, ) - WalletBlock( - modifier = Modifier - .padding(top = 24.dp), - title = stringResourceSafe(R.string.wallet_create_mobile_title), - description = stringResourceSafe(R.string.wallet_create_mobile_description), - badge = { - Box( - modifier = Modifier - .background( - color = TangemTheme.colors.field.focused, - shape = TangemTheme.shapes.roundedCorners8, - ) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - text = stringResourceSafe(R.string.common_free), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.secondary, - ) - } - }, - onClick = state.onMobileWalletClick, - ) - WalletBlock( - title = stringResourceSafe(R.string.wallet_create_hardware_title), - description = stringResourceSafe(R.string.wallet_create_hardware_description), - badge = { - Box( - modifier = Modifier - .background( - color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), - shape = TangemTheme.shapes.roundedCorners8, - ) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - text = stringResourceSafe(R.string.wallet_create_hardware_badge, state.hardwareWalletPrice), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.accent, - ) - } - }, - onClick = state.onHardwareWalletClick, - ) + state.blocks.forEach { block -> + WalletBlock( + modifier = Modifier + .padding(top = 8.dp), + title = block.title.resolveReference(), + description = block.description.resolveReference(), + features = block.features, + badge = block.titleLabel?.let { + { Label(it) } + }, + onClick = block.onClick, + ) + } } AnimatedVisibility(state.showAlreadyHaveWallet) { AlreadyHaveTangemWalletBlock( - onScanClick = state.onScanClick, + onBuyClick = state.onBuyClick, isScanInProgress = state.isScanInProgress, ) } @@ -143,8 +118,9 @@ private fun WalletBlock( title: String, description: String, onClick: () -> Unit, + features: ImmutableList, modifier: Modifier = Modifier, - badge: @Composable () -> Unit, + badge: @Composable (() -> Unit)? = null, ) { Column( modifier = modifier @@ -152,11 +128,14 @@ private fun WalletBlock( .padding(top = 8.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) .background( - color = TangemTheme.colors.field.primary, + color = TangemTheme.colors.background.primary, shape = TangemTheme.shapes.roundedCornersXMedium, ) .clickable(onClick = onClick) - .padding(16.dp), + .padding( + horizontal = 16.dp, + vertical = 12.dp, + ), ) { Row { Text( @@ -167,7 +146,7 @@ private fun WalletBlock( style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, ) - badge() + badge?.invoke() } Text( modifier = Modifier @@ -176,24 +155,57 @@ private fun WalletBlock( style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, ) + if (features.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp), + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + features.forEach { + Feature( + feature = it, + modifier = Modifier + .padding(top = 12.dp), + ) + } + } + } +} + +@Composable +private fun Feature(feature: CreateWalletSelectionUM.Feature, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size16), + painter = painterResource(id = feature.iconResId), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 6.dp), + text = feature.title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) } } @Composable private fun AlreadyHaveTangemWalletBlock( - onScanClick: () -> Unit, + onBuyClick: () -> Unit, isScanInProgress: Boolean, modifier: Modifier = Modifier, ) { - var buttonWidth by remember { mutableStateOf(0) } - val density = LocalDensity.current - Row( modifier = modifier .fillMaxWidth() .padding(16.dp) .background( - color = TangemTheme.colors.field.primary, + color = TangemTheme.colors.background.primary, shape = TangemTheme.shapes.roundedCornersXMedium, ) .padding( @@ -206,30 +218,16 @@ private fun AlreadyHaveTangemWalletBlock( modifier = Modifier .weight(1f) .padding(end = 16.dp), - text = stringResourceSafe(R.string.wallet_create_scan_question), + text = stringResourceSafe(R.string.wallet_add_hardware_purchase), style = TangemTheme.typography.button, color = TangemTheme.colors.text.primary1, ) - TangemButton( - modifier = Modifier - .conditional(buttonWidth > 0) { - width(with(density) { buttonWidth.toDp() }) - } - .onGloballyPositioned { coordinates -> - if (buttonWidth == 0) { - buttonWidth = coordinates.size.width - } - }, - text = stringResourceSafe(R.string.wallet_create_scan_title), - onClick = onScanClick, - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), + SecondaryButton( + text = stringResourceSafe(R.string.wallet_import_buy_title), + onClick = onBuyClick, size = TangemButtonSize.RoundedAction, showProgress = isScanInProgress, - colors = TangemButtonsDefaults.secondaryButtonColors, - textStyle = TangemTheme.typography.subtitle1, - enabled = true, - animateContentChange = true, ) } } @@ -241,11 +239,45 @@ private fun PreviewCreateWalletContent() { TangemThemePreview { CreateWalletSelectionContent( state = CreateWalletSelectionUM( - showAlreadyHaveWallet = true, - onBackClick = {}, - onMobileWalletClick = {}, - onHardwareWalletClick = {}, - onScanClick = {}, + onBackClick = { }, + blocks = persistentListOf( + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_hardware_title), + titleLabel = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), + description = resourceReference(R.string.wallet_add_hardware_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_add_wallet_16, + title = resourceReference(R.string.wallet_add_hardware_info_create), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = { }, + ), + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_mobile_title), + titleLabel = null, + description = resourceReference(R.string.wallet_add_mobile_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_mobile_wallet_16, + title = resourceReference(R.string.hw_create_title), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = { }, + ), + ), + onBuyClick = { }, ), ) } diff --git a/features/create-wallet-start/api/.gitignore b/features/create-wallet-start/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/create-wallet-start/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/create-wallet-start/api/build.gradle.kts b/features/create-wallet-start/api/build.gradle.kts new file mode 100644 index 0000000000..8c60a57f79 --- /dev/null +++ b/features/create-wallet-start/api/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.createwalletstart.api" +} + +dependencies { + /* Project - Domain */ + implementation(projects.domain.models) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/create-wallet-start/api/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartComponent.kt b/features/create-wallet-start/api/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartComponent.kt new file mode 100644 index 0000000000..120293bd9a --- /dev/null +++ b/features/create-wallet-start/api/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.createwalletstart + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface CreateWalletStartComponent : ComposableContentComponent { + + data class Params( + val mode: Mode, + ) + + enum class Mode { + ColdWallet, + HotWallet, + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/.gitignore b/features/create-wallet-start/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/create-wallet-start/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/create-wallet-start/impl/build.gradle.kts b/features/create-wallet-start/impl/build.gradle.kts new file mode 100644 index 0000000000..6909f9d54c --- /dev/null +++ b/features/create-wallet-start/impl/build.gradle.kts @@ -0,0 +1,72 @@ +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.createwalletstart.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.createWalletStart.api) + + /** Project - Domain */ + implementation(projects.domain.card) + implementation(projects.domain.settings) + implementation(projects.domain.wallets) + implementation(projects.domain.models) + + /** Core modules */ + implementation(projects.core.configToggles) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.ui) + implementation(projects.core.res) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.datasource) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** Tangem libraries */ + implementation(projects.libs.tangemSdkApi) + implementation(tangemDeps.card.core) + implementation(tangemDeps.card.android) { + exclude(module = "joda-time") + } + + /** AndroidX libraries */ + implementation(deps.androidx.core.ktx) + implementation(deps.lifecycle.runtime.ktx) + + /** Compose libraries */ + implementation(deps.compose.material3) + implementation(deps.compose.animation) + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.lottie.compose) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + implementation(deps.androidx.datastore) + + /** Other libraries */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.serialization) + implementation(deps.timber) + implementation(deps.firebase.crashlytics) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt new file mode 100644 index 0000000000..6441bbb2e2 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -0,0 +1,243 @@ +package com.tangem.features.createwalletstart + +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +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.GlobalUiMessageSender +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.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.createwalletstart.entity.CreateWalletStartUM +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 + +private const val HIDE_PROGRESS_DELAY = 400L + +@Suppress("LongParameterList") +@ModelScoped +internal class CreateWalletStartModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val scanCardProcessor: ScanCardProcessor, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val settingsRepository: SettingsRepository, + private val analyticsEventHandler: AnalyticsEventHandler, + private val appRouter: AppRouter, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + private val saveWalletUseCase: SaveWalletUseCase, + private val userWalletsListRepository: UserWalletsListRepository, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, +) : Model() { + + private val params = paramsContainer.require() + + internal val uiState: StateFlow + field = MutableStateFlow( + when (params.mode) { + CreateWalletStartComponent.Mode.ColdWallet -> CreateWalletStartUM( + title = resourceReference(R.string.common_tangem_wallet), + description = resourceReference(R.string.welcome_create_wallet_hardware_description), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ), + ), + imageResId = R.drawable.img_hardware_wallet, + showScanSecondaryButton = true, + onPrimaryButtonClick = ::onBuyClick, + primaryButtonText = resourceReference(R.string.details_buy_wallet), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodDescription = resourceReference(R.string.welcome_create_wallet_mobile_description), + otherMethodClick = ::onStartWithMobileWalletClick, + onBackClick = { router.pop() }, + onScanClick = ::onScanClick, + isScanInProgress = false, + ) + CreateWalletStartComponent.Mode.HotWallet -> CreateWalletStartUM( + title = resourceReference(R.string.hw_mobile_wallet), + description = resourceReference(R.string.welcome_create_wallet_mobile_description_full), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_seamless), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_one_tap), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_stack_fill_new_16, + text = resourceReference(R.string.welcome_create_wallet_feature_assets), + ), + ), + imageResId = R.drawable.img_mobile_wallet, + showScanSecondaryButton = false, + onPrimaryButtonClick = ::onStartWithMobileWalletClick, + primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title), + otherMethodDescription = resourceReference(R.string.welcome_create_wallet_use_hardware_description), + otherMethodClick = ::onBuyClick, + onBackClick = { router.pop() }, + onScanClick = ::onScanClick, + isScanInProgress = false, + ) + }, + ) + + private fun onScanClick() { + scanCard() + } + + private fun onStartWithMobileWalletClick() { + router.push(AppRoute.CreateMobileWallet) + } + + private fun onBuyClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + 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 { + appRouter.replaceAll(AppRoute.Wallet) + } + } + } + }, + ifRight = { + setLoading(false) + sendSignedInCardAnalyticsEvent(scanResponse) + appRouter.replaceAll(AppRoute.Wallet) + }, + ) + } + + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + 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(), + 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/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/DefaultCreateWalletStartComponent.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/DefaultCreateWalletStartComponent.kt new file mode 100644 index 0000000000..598669a656 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/DefaultCreateWalletStartComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.features.createwalletstart + +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.components.SystemBarsIconsDisposable +import com.tangem.core.ui.res.ForceDarkTheme +import com.tangem.features.createwalletstart.ui.CreateWalletStartContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCreateWalletStartComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: CreateWalletStartComponent.Params, +) : CreateWalletStartComponent, AppComponentContext by context { + + private val model: CreateWalletStartModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + SystemBarsIconsDisposable(darkIcons = false) + ForceDarkTheme { + CreateWalletStartContent( + state = state, + modifier = modifier, + ) + } + } + + @AssistedFactory + interface Factory : CreateWalletStartComponent.Factory { + override fun create( + context: AppComponentContext, + params: CreateWalletStartComponent.Params, + ): DefaultCreateWalletStartComponent + } +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/di/CreateWalletStartModule.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/di/CreateWalletStartModule.kt new file mode 100644 index 0000000000..c534d77b9a --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/di/CreateWalletStartModule.kt @@ -0,0 +1,33 @@ +package com.tangem.features.createwalletstart.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.createwalletstart.CreateWalletStartComponent +import com.tangem.features.createwalletstart.CreateWalletStartModel +import com.tangem.features.createwalletstart.DefaultCreateWalletStartComponent +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 object CreateWalletStartModule + +@Module +@InstallIn(SingletonComponent::class) +internal interface CreateWalletStartModuleBinds { + + @Binds + @Singleton + fun bindCreateWalletStartComponentFactory( + impl: DefaultCreateWalletStartComponent.Factory, + ): CreateWalletStartComponent.Factory + + @Binds + @IntoMap + @ClassKey(CreateWalletStartModel::class) + fun bindCreateWalletStartModel(model: CreateWalletStartModel): Model +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/entity/CreateWalletStartUM.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/entity/CreateWalletStartUM.kt new file mode 100644 index 0000000000..58f58d2bc0 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/entity/CreateWalletStartUM.kt @@ -0,0 +1,25 @@ +package com.tangem.features.createwalletstart.entity + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class CreateWalletStartUM( + val title: TextReference, + val description: TextReference, + val featureItems: ImmutableList, + val imageResId: Int, + val isScanInProgress: Boolean, + val showScanSecondaryButton: Boolean, + val primaryButtonText: TextReference, + val onPrimaryButtonClick: () -> Unit, + val otherMethodDescription: TextReference, + val otherMethodTitle: TextReference, + val otherMethodClick: () -> Unit, + val onScanClick: () -> Unit, + val onBackClick: () -> Unit, +) { + data class FeatureItem( + val iconResId: Int, + val text: TextReference, + ) +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt new file mode 100644 index 0000000000..9c6e3fedf3 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt @@ -0,0 +1,486 @@ +package com.tangem.features.createwalletstart.ui + +import android.annotation.SuppressLint +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.* +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.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +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 androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButtonIconEnd +import com.tangem.core.ui.components.bottomFade +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.createwalletstart.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlin.math.max + +@Suppress("LongMethod", "MagicNumber") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +internal fun CreateWalletStartContent(state: CreateWalletStartUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + brush = Brush.verticalGradient( + listOf( + TangemColorPalette.Dark6, + TangemColorPalette.Black, + ), + ), + ) + .fillMaxSize() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + navigationIcon = { + IconButton(onClick = state.onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_back_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + }, + title = { }, + ) + Box( + modifier = Modifier + .weight(1f) + .bottomFade(height = 24.dp), + ) { + AdaptiveScrollableContent( + topContent = { + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 32.dp, + top = 16.dp, + end = 32.dp, + ), + text = state.title.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 32.dp, + top = 8.dp, + end = 32.dp, + ), + text = state.description.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + 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), + ) { + state.featureItems.forEach { + FeatureItem( + iconResId = it.iconResId, + text = it.text, + ) + } + } + }, + imageContent = { + Image( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding( + vertical = 12.dp, + horizontal = 16.dp, + ), + painter = painterResource(id = state.imageResId), + contentDescription = null, + contentScale = ContentScale.Fit, + ) + }, + bottomContent = { + if (state.showScanSecondaryButton) { + SecondaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResourceSafe(R.string.welcome_unlock_card), + onClick = state.onScanClick, + showProgress = state.isScanInProgress, + iconResId = R.drawable.ic_tangem_24, + ) + } + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 8.dp, + end = 16.dp, + ), + text = state.primaryButtonText.resolveReference(), + onClick = state.onPrimaryButtonClick, + ) + Row( + modifier = Modifier + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashedGradientLine( + modifier = Modifier + .weight(1f) + .height(16.dp), + ) + Text( + text = stringResourceSafe(R.string.welcome_create_wallet_other_method), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + DashedGradientLine( + modifier = Modifier + .weight(1f) + .height(16.dp) + .scale(scaleX = -1f, scaleY = 1f), + ) + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 16.dp, + end = 16.dp, + ), + text = state.otherMethodDescription.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + Row( + modifier = Modifier + .wrapContentWidth() + .clickable { state.otherMethodClick() } + .padding( + horizontal = 16.dp, + vertical = 12.dp, + ), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = state.otherMethodTitle.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_18x24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + }, + minImageHeight = 160.dp, + ) + } + if (!state.showScanSecondaryButton) { + FlowRow( + modifier = Modifier + .wrapContentWidth() + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + bottom = 8.dp, + ), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResourceSafe(R.string.welcome_create_wallet_already_have), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + ) + Spacer(modifier = Modifier.size(4.dp)) + Row( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { state.onScanClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResourceSafe(R.string.wallet_create_scan_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + Spacer(modifier = Modifier.size(2.dp)) + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.ic_tangem_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + } + } + Spacer(modifier = Modifier.size(16.dp)) + } +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +private fun AdaptiveScrollableContent( + minImageHeight: Dp, + modifier: Modifier = Modifier, + topContent: @Composable () -> Unit, + imageContent: @Composable () -> Unit, + bottomContent: @Composable () -> Unit, +) { + BoxWithConstraints( + modifier = modifier.fillMaxSize(), + ) { + val density = LocalDensity.current + val viewportHeight = maxHeight + val minImageHeightPx = with(density) { minImageHeight.roundToPx() } + val viewportHeightPx = with(density) { viewportHeight.roundToPx() } + Layout( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + content = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + topContent() + } + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + imageContent() + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + bottomContent() + } + }, + ) { measurables, constraints -> + val topPlaceable = measurables[0].measure( + constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity), + ) + val bottomPlaceable = measurables[2].measure( + constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity), + ) + val imageIntrinsicHeight = measurables[1].maxIntrinsicHeight(constraints.maxWidth) + val availableHeightForImage = max(0, viewportHeightPx - topPlaceable.height - bottomPlaceable.height) + val targetImageHeight = when { + imageIntrinsicHeight < minImageHeightPx -> minImageHeightPx + imageIntrinsicHeight > availableHeightForImage -> max(minImageHeightPx, availableHeightForImage) + else -> imageIntrinsicHeight + } + val imagePlaceable = measurables[1].measure( + constraints.copy( + minHeight = targetImageHeight, + maxHeight = targetImageHeight, + ), + ) + val totalContentHeight = topPlaceable.height + imagePlaceable.height + bottomPlaceable.height + layout(constraints.maxWidth, totalContentHeight) { + var yOffset = 0 + topPlaceable.placeRelative(0, yOffset) + yOffset += topPlaceable.height + imagePlaceable.placeRelative(0, yOffset) + yOffset += imagePlaceable.height + bottomPlaceable.placeRelative(0, yOffset) + } + } + } +} + +@Composable +private fun DashedGradientLine(modifier: Modifier = Modifier) { + val density = LocalDensity.current + + val strokeColor = TangemTheme.colors.stroke.primary + + Canvas(modifier = modifier) { + val strokePx = with(density) { 4.dp.toPx() } + val dashPx = with(density) { 4.dp.toPx() } + val gapPx = with(density) { 8.dp.toPx() } + + val width = size.width + val centerY = size.height / 2 + + val brush = Brush.linearGradient( + colors = listOf(strokeColor.copy(alpha = 0f), strokeColor), + start = Offset(0f, 0f), + end = Offset(width, 0f), + ) + + val pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashPx, gapPx), 0f) + + drawLine( + brush = brush, + start = Offset(0f, centerY), + end = Offset(width, centerY), + strokeWidth = strokePx, + pathEffect = pathEffect, + cap = StrokeCap.Round, + ) + } +} + +@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, + ) + } +} + +private class CreateWalletStartStateProvider : CollectionPreviewParameterProvider( + collection = listOf( + CreateWalletStartUM( + title = resourceReference(R.string.common_tangem_wallet), + description = resourceReference(R.string.welcome_create_wallet_hardware_description), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ), + ), + imageResId = R.drawable.img_hardware_wallet, + showScanSecondaryButton = true, + onPrimaryButtonClick = { }, + primaryButtonText = resourceReference(R.string.details_buy_wallet), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodDescription = resourceReference( + R.string.welcome_create_wallet_mobile_description, + ), + otherMethodClick = { }, + onBackClick = { }, + onScanClick = { }, + isScanInProgress = false, + ), + CreateWalletStartUM( + title = resourceReference(R.string.hw_mobile_wallet), + description = resourceReference(R.string.welcome_create_wallet_mobile_description_full), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_seamless), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_one_tap), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_stack_fill_new_16, + text = resourceReference(R.string.welcome_create_wallet_feature_assets), + ), + ), + imageResId = R.drawable.img_mobile_wallet, + showScanSecondaryButton = false, + onPrimaryButtonClick = { }, + primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title), + otherMethodDescription = resourceReference( + R.string.welcome_create_wallet_use_hardware_description, + ), + otherMethodClick = { }, + onBackClick = { }, + onScanClick = { }, + isScanInProgress = false, + ), + ), +) + +@Preview(showBackground = true, widthDp = 360, heightDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 360, heightDp = 560, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 360, heightDp = 840, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewCreateWalletStartContent( + @PreviewParameter(CreateWalletStartStateProvider::class) param: CreateWalletStartUM, +) { + TangemThemePreview { + CreateWalletStartContent( + state = param, + ) + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt index f217f837ce..a8ef5eb141 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt @@ -2,7 +2,6 @@ package com.tangem.features.details.entity import androidx.compose.runtime.Immutable import com.tangem.common.ui.userwallet.state.UserWalletItemUM -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -12,5 +11,4 @@ internal data class UserWalletListUM( val isWalletSavingInProgress: Boolean, val addNewWalletText: TextReference, val onAddNewWalletClick: () -> Unit, - val addWalletBottomSheet: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty, ) \ No newline at end of file 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 9ced337ddf..a096f5231d 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 @@ -6,14 +6,8 @@ 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.components.bottomsheets.BottomSheetOption -import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheetContent -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.impl.R @@ -27,7 +21,6 @@ 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 javax.inject.Inject @Suppress("LongParameterList") @@ -38,8 +31,6 @@ internal class UserWalletListModel @Inject constructor( private val router: Router, private val messageSender: UiMessageSender, override val dispatchers: CoroutineDispatcherProvider, - private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, - private val urlOpener: UrlOpener, private val userWalletSaver: UserWalletSaver, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -58,7 +49,6 @@ internal class UserWalletListModel @Inject constructor( isWalletSavingInProgress = false, addNewWalletText = TextReference.EMPTY, onAddNewWalletClick = ::onAddNewWalletClick, - addWalletBottomSheet = TangemBottomSheetConfig.Empty, ), ) @@ -90,62 +80,11 @@ internal class UserWalletListModel @Inject constructor( private fun onAddNewWalletClick() { if (hotWalletFeatureToggles.isHotWalletEnabled) { - state.update { currentState -> - currentState.copy( - addWalletBottomSheet = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = ::dismissAddWalletBottomSheet, - content = createAddWalletBottomSheetContent(), - ), - ) - } + router.push(AppRoute.CreateWalletSelection) } else { withProgress(isWalletSavingInProgress) { userWalletSaver.scanAndSaveUserWallet(modelScope) } } } - - private fun dismissAddWalletBottomSheet() { - state.update { currentState -> - currentState.copy( - addWalletBottomSheet = currentState.addWalletBottomSheet.copy(isShown = false), - ) - } - } - - private fun createAddWalletBottomSheetContent(): OptionsBottomSheetContent { - return OptionsBottomSheetContent( - options = persistentListOf( - BottomSheetOption( - key = ADD_WALLET_KEY_CREATE, - label = resourceReference(string.home_button_create_new_wallet), - ), - BottomSheetOption( - key = ADD_WALLET_KEY_ADD, - label = resourceReference(string.home_button_add_existing_wallet), - ), - BottomSheetOption( - key = ADD_WALLET_KEY_BUY, - label = resourceReference(string.details_buy_wallet), - ), - ), - onOptionClick = { optionKey -> - dismissAddWalletBottomSheet() - when (optionKey) { - ADD_WALLET_KEY_CREATE -> router.push(AppRoute.CreateWalletSelection) - ADD_WALLET_KEY_ADD -> router.push(AppRoute.AddExistingWallet) - ADD_WALLET_KEY_BUY -> modelScope.launch { - generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } - } - } - }, - ) - } - - companion object { - private const val ADD_WALLET_KEY_CREATE = "create" - private const val ADD_WALLET_KEY_ADD = "add" - private const val ADD_WALLET_KEY_BUY = "buy" - } } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt index bea3f8c35d..ec27900868 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt @@ -15,13 +15,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.common.ui.userwallet.UserWalletItem -import com.tangem.core.ui.R.* import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheet -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig 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.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.details.component.UserWalletListComponent @@ -48,8 +44,6 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M onClick = state.onAddNewWalletClick, ) } - - AddWalletBottomSheet(state.addWalletBottomSheet) } @Composable @@ -100,15 +94,6 @@ private fun AddWalletButton( } } -@Composable -private fun AddWalletBottomSheet(config: TangemBottomSheetConfig) { - OptionsBottomSheet( - config = config, - title = resourceReference(string.auth_info_add_wallet_title), - containerColor = TangemTheme.colors.background.tertiary, - ) -} - // region Preview @Composable @Preview(showBackground = true, widthDp = 360) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index f954397c48..1dd5728ec4 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -86,8 +86,7 @@ internal class HomeModel @Inject constructor( onScanClick = ::onScanClick, onShopClick = ::onShopClick, onSearchTokensClick = ::onSearchTokensClick, - onCreateNewWalletClick = ::onCreateNewWalletClick, - onAddExistingWalletClick = ::onAddExistingWalletClick, + onGetStartedClick = ::onGetStartedClick, ), ) @@ -145,12 +144,8 @@ internal class HomeModel @Inject constructor( router.push(AppRoute.ManageTokens(Source.STORIES)) } - private fun onCreateNewWalletClick() { - router.push(AppRoute.CreateWalletSelection) - } - - private fun onAddExistingWalletClick() { - router.push(AppRoute.AddExistingWallet) + private fun onGetStartedClick() { + router.push(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.ColdWallet)) } private fun scanCard() { @@ -240,7 +235,7 @@ internal class HomeModel @Inject constructor( _uiState.update { it.copy(scanInProgress = isLoading) } } - fun handleScanError(error: TangemError) { + private fun handleScanError(error: TangemError) { when (error) { is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable() is TangemSdkError -> Timber.e(error, "Scan error occurred") diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt index 24025e1627..7277504058 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt @@ -17,9 +17,7 @@ internal fun Home(state: HomeUM, isV2StoriesEnabled: Boolean, modifier: Modifier StoriesScreenV2( modifier = modifier, state = state, - onCreateNewWalletButtonClick = state.onCreateNewWalletClick, - onAddExistingWalletButtonClick = state.onAddExistingWalletClick, - onScanButtonClick = state.onScanClick, + onGetStartedClick = state.onGetStartedClick, ) } else { StoriesScreen( diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt index 0554472199..3f790b2e79 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt @@ -29,13 +29,7 @@ import com.tangem.core.ui.R import com.tangem.features.home.impl.ui.state.HomeUM @Composable -internal fun StoriesScreenV2( - state: HomeUM, - onCreateNewWalletButtonClick: () -> Unit, - onAddExistingWalletButtonClick: () -> Unit, - onScanButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { +internal fun StoriesScreenV2(state: HomeUM, onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) { var currentStory by remember { mutableStateOf(state.firstStory) } val currentStoryIndex by rememberUpdatedState(newValue = state.stepOf(currentStory)) @@ -64,9 +58,7 @@ internal fun StoriesScreenV2( isScanInProgress = state.scanInProgress, onGoToPreviousStory = goToPreviousStory, onGoToNextStory = goToNextStory, - onCreateNewWalletButtonClick = onCreateNewWalletButtonClick, - onAddExistingWalletButtonClick = onAddExistingWalletButtonClick, - onScanButtonClick = onScanButtonClick, + onGetStartedClick = onGetStartedClick, ), ) } @@ -176,10 +168,7 @@ private fun StoriesScreenContentV2(config: StoriesScreenContentV2Config, modifie ) { HomeButtonsV2( modifier = Modifier.fillMaxWidth(), - btnScanStateInProgress = config.isScanInProgress, - onScanButtonClick = config.onScanButtonClick, - onCreateNewWalletButtonClick = config.onCreateNewWalletButtonClick, - onAddExistingWalletButtonClick = config.onAddExistingWalletButtonClick, + onGetStartedClick = config.onGetStartedClick, ) } } @@ -192,9 +181,7 @@ private data class StoriesScreenContentV2Config( val isScanInProgress: Boolean, val onGoToPreviousStory: () -> Unit = {}, val onGoToNextStory: () -> Unit = {}, - val onCreateNewWalletButtonClick: () -> Unit = {}, - val onAddExistingWalletButtonClick: () -> Unit = {}, - val onScanButtonClick: () -> Unit = {}, + val onGetStartedClick: () -> Unit = {}, ) // region Preview diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt index c68825891d..cd2c7ae621 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt @@ -9,116 +9,42 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -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.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition 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.StoriesScreenTestTags import com.tangem.core.ui.R @Composable -internal fun HomeButtonsV2( - btnScanStateInProgress: Boolean, - onScanButtonClick: () -> Unit, - onCreateNewWalletButtonClick: () -> Unit, - onAddExistingWalletButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { +internal fun HomeButtonsV2(onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - CreateNewWalletButton( - modifier = Modifier - .fillMaxWidth() - .testTag(StoriesScreenTestTags.CREATE_NEW_WALLET_BUTTON), - onClick = onCreateNewWalletButtonClick, - ) - AddExistingWalletButton( - modifier = Modifier - .fillMaxWidth() - .testTag(StoriesScreenTestTags.ADD_EXISTING_WALLET_BUTTON), - onClick = onAddExistingWalletButtonClick, - ) - ScanCardButton( - modifier = Modifier - .fillMaxWidth() - .testTag(StoriesScreenTestTags.SCAN_BUTTON), - showProgress = btnScanStateInProgress, - onClick = onScanButtonClick, + StoriesButton( + modifier = modifier, + text = stringResourceSafe(id = R.string.common_get_started), + useDarkerColors = false, + onClick = onGetStartedClick, ) } } -@Composable -private fun CreateNewWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_create_new_wallet), - useDarkerColors = false, - onClick = onClick, - ) -} - -@Composable -private fun AddExistingWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_add_existing_wallet), - useDarkerColors = true, - onClick = onClick, - ) -} - -@Composable -private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_scan), - useDarkerColors = true, - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), - onClick = onClick, - showProgress = showProgress, - ) -} - // region Preview @Preview(showBackground = true, widthDp = 360) @Composable -private fun HomeButtonsV2Preview(@PreviewParameter(HomeButtonsV2ParameterProvider::class) state: HomeButtonsV2State) { +private fun HomeButtonsV2Preview() { TangemThemePreview { Box( modifier = Modifier.background(Color.Black), ) { HomeButtonsV2( - btnScanStateInProgress = state.btnScanStateInProgress, - onCreateNewWalletButtonClick = {}, - onAddExistingWalletButtonClick = {}, - onScanButtonClick = {}, + onGetStartedClick = {}, modifier = Modifier.padding(all = TangemTheme.dimens.spacing16), ) } } } - -private class HomeButtonsV2ParameterProvider : CollectionPreviewParameterProvider( - collection = listOf( - HomeButtonsV2State( - btnScanStateInProgress = false, - ), - HomeButtonsV2State( - btnScanStateInProgress = true, - ), - ), -) - -private data class HomeButtonsV2State( - val btnScanStateInProgress: Boolean, -) // endregion Preview \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt index d727df8990..e8f1e02f64 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt @@ -8,8 +8,7 @@ data class HomeUM( val onScanClick: () -> Unit, val onShopClick: () -> Unit, val onSearchTokensClick: () -> Unit, - val onCreateNewWalletClick: () -> Unit, - val onAddExistingWalletClick: () -> Unit, + val onGetStartedClick: () -> Unit, ) { val firstStory: Stories get() = stories[0] diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt index 5c9a7148de..ad3e784dd7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt @@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS @@ -31,6 +32,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, private val userWalletsListRepository: UserWalletsListRepository, + private val canUseBiometryUseCase: CanUseBiometryUseCase, ) : Model() { private val result = MutableStateFlow(null) @@ -60,7 +62,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( it.copy( isShown = true, accessCode = "", - useBiometricVisible = attemptRequest.hasBiometry, + useBiometricVisible = attemptRequest.isBiometryButtonVisible(), onAccessCodeChange = ::onAccessCodeChange, ) } @@ -83,7 +85,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( it.copy( accessCodeColor = PinTextColor.WrongCode, onAccessCodeChange = {}, - useBiometricVisible = currentRequest.hasBiometry, + useBiometricVisible = currentRequest.isBiometryButtonVisible(), ) } delay(timeMillis = 500) // Delay to show the wrong access code state @@ -212,6 +214,9 @@ internal class HotAccessCodeRequestModel @Inject constructor( dismiss() } + private suspend fun HotWalletPasswordRequester.AttemptRequest.isBiometryButtonVisible(): Boolean = + hasBiometry && canUseBiometryUseCase() + private fun dismissState() { uiState.update { it.copy(isShown = false) diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt index e680fabf26..bff75e7fb5 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt @@ -2,21 +2,13 @@ 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.wallet.UserWalletId interface ManageTokensComponent : ComposableContentComponent { data class Params( val mode: ManageTokensMode, val source: ManageTokensSource, - ) { - constructor(userWalletId: UserWalletId?, source: ManageTokensSource) : this( - source = source, - mode = userWalletId - ?.let { ManageTokensMode.Wallet(userWalletId) } - ?: ManageTokensMode.None, - ) - } + ) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt index 3c34dd0f34..bdd3cdbc10 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt @@ -17,6 +17,13 @@ sealed interface ManageTokensMode { } sealed interface AddCustomTokenMode { - data class Wallet(val userWalletId: UserWalletId) : AddCustomTokenMode + + val userWalletId: UserWalletId + get() = when (this) { + is Account -> accountId.userWalletId + is Wallet -> userWalletId + } + + data class Wallet(override val userWalletId: UserWalletId) : AddCustomTokenMode data class Account(val accountId: AccountId) : AddCustomTokenMode } \ 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 0506848d40..58cffdfd57 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { implementation(projects.common.ui) /* Project - Domain */ + implementation(projects.domain.account.status) + implementation(projects.domain.account) implementation(projects.domain.card) implementation(projects.domain.legacy) implementation(projects.domain.manageTokens) @@ -35,6 +37,14 @@ dependencies { implementation(projects.domain.swap.models) implementation(projects.domain.notifications) + // region Project - Libs + implementation(projects.libs.crypto) + // endregion + + // region Tangem SDKs + implementation(tangemDeps.blockchain) + // endregion + /* AndroidX */ implementation(deps.androidx.activity.compose) implementation(deps.lifecycle.compose) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt index 56323dcd9a..13fca6567a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt @@ -54,6 +54,7 @@ internal sealed class ManageTokensUM { isSavingInProgress: Boolean = this is ManageContent && this.isSavingInProgress, scrollToTop: StateEvent = this.scrollToTop, needToInteractWithColdWallet: Boolean = this is ManageContent && this.needToInteractWithColdWallet, + topBar: ManageTokensTopBarUM? = this.topBar, ): ManageTokensUM { return when (this) { is ManageContent -> copy( @@ -65,6 +66,7 @@ internal sealed class ManageTokensUM { isSavingInProgress = isSavingInProgress, scrollToTop = scrollToTop, needToInteractWithColdWallet = needToInteractWithColdWallet, + topBar = topBar, ) is ReadContent -> copy( search = search, @@ -72,6 +74,7 @@ internal sealed class ManageTokensUM { isInitialBatchLoading = isInitialBatchLoading, isNextBatchLoading = isNextBatchLoading, scrollToTop = scrollToTop, + topBar = topBar, ) } } 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 3bb119e580..cadcf9f356 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 @@ -3,13 +3,22 @@ package com.tangem.features.managetokens.model import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.account.toUM 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.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +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.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.managetokens.GetSupportedNetworksUseCase +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent @@ -25,10 +34,12 @@ import com.tangem.features.managetokens.entity.item.SelectableItemUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.mapper.toCurrencyNetworkModel import com.tangem.features.managetokens.utils.mapper.toDerivationPathModel +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -38,6 +49,8 @@ internal class CustomTokenSelectorModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val getSupportedNetworksUseCase: GetSupportedNetworksUseCase, private val messageSender: UiMessageSender, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val accountsFeatureToggles: AccountsFeatureToggles, paramsContainer: ParamsContainer, ) : Model() { @@ -146,9 +159,8 @@ internal class CustomTokenSelectorModel @Inject constructor( return derivationPaths } - private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") - is AddCustomTokenMode.Wallet -> getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e -> + private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List { + return getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e -> val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)) messageSender.send(message) @@ -168,7 +180,60 @@ internal class CustomTokenSelectorModel @Inject constructor( fun selectCustomDerivationPath(value: SelectedDerivationPath) { when (params) { is NetworkSelector -> return - is DerivationPathSelector -> params.onDerivationPathSelected(value) + is DerivationPathSelector -> if (accountsFeatureToggles.isFeatureEnabled) { + params.checkAccountDerivation(value) + } else { + params.onDerivationPathSelected(value) + } } } + + private fun DerivationPathSelector.checkAccountDerivation(derivationPath: SelectedDerivationPath) = + modelScope.launch { + val accountName = derivationPath.id + ?.let { Blockchain.fromId(it.rawId.value) }?.let(::AccountNodeRecognizer) + ?.let { recognizer -> derivationPath.value.value?.let { recognizer.recognize(it) } } + ?.let { accountNode -> + fun AccountStatus.CryptoPortfolio.sameNodeAndNotMain() = !this.account.isMainAccount && + this.account.derivationIndex.value.toLong() == accountNode + + val accounts = singleAccountStatusListSupplier(mode.userWalletId) + .first().accountStatuses + val account = accounts.find { + when (it) { + is AccountStatus.CryptoPortfolio -> it.sameNodeAndNotMain() + } + } + val accountName = when (account) { + is AccountStatus.CryptoPortfolio -> account.account.accountName.toUM() + null -> null + } + accountName + } + + if (accountName == null) { + onDerivationPathSelected(derivationPath) + } else { + showAccountNameExist( + accountName = accountName.value, + onClick = { onDerivationPathSelected(derivationPath) }, + ) + } + } + + private fun showAccountNameExist(accountName: TextReference, onClick: () -> Unit) { + val firstAction = EventMessageAction( + title = resourceReference(R.string.common_got_it), + onClick = onClick, + ) + val dialogMessage = DialogMessage( + title = resourceReference(R.string.custom_token_another_account_dialog_title), + message = resourceReference( + R.string.custom_token_another_account_dialog_description, + wrappedList(accountName), + ), + firstActionBuilder = { firstAction }, + ) + messageSender.send(dialogMessage) + } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt index c34a6ba32d..74ee2da6c5 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -17,6 +17,7 @@ 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.status.supplier.SingleAccountStatusListSupplier import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent import com.tangem.features.managetokens.component.ManageTokensComponent @@ -40,13 +41,14 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class ManageTokensModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val messageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, manageTokensListManagerFactory: ManageTokensListManager.Factory, manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, paramsContainer: ParamsContainer, @@ -86,6 +88,7 @@ internal class ManageTokensModel @Inject constructor( modelScope.launch { manageTokensListManager.launchPagination(isCollapsed = true) } + checkIsSupportAddCustomTokens() } fun reloadList() { @@ -105,16 +108,34 @@ internal class ManageTokensModel @Inject constructor( } } + private fun getTopBarInitialState(): ManageTokensTopBarUM = when (params.mode) { + is ManageTokensMode.Wallet -> manageContentTopBar() + is ManageTokensMode.Account -> ManageTokensTopBarUM.ReadContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = router::pop, + ) + ManageTokensMode.None -> ManageTokensTopBarUM.ReadContent( + title = resourceReference(R.string.common_search_tokens), + onBackButtonClick = router::pop, + ) + } + + private fun manageContentTopBar() = ManageTokensTopBarUM.ManageContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = router::pop, + endButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_plus_24, + onClicked = ::navigateToAddCustomToken, + ), + ) + private fun createReadContentModel(): ManageTokensUM.ReadContent { return ManageTokensUM.ReadContent( popBack = router::pop, isInitialBatchLoading = true, isNextBatchLoading = false, items = getLoadingItems(), - topBar = ManageTokensTopBarUM.ReadContent( - title = resourceReference(R.string.common_search_tokens), - onBackButtonClick = router::pop, - ), + topBar = getTopBarInitialState(), search = SearchBarUM( placeholderText = resourceReference(R.string.common_search), query = "", @@ -132,14 +153,7 @@ internal class ManageTokensModel @Inject constructor( isInitialBatchLoading = true, isNextBatchLoading = false, items = getLoadingItems(), - topBar = ManageTokensTopBarUM.ManageContent( - title = resourceReference(id = R.string.main_manage_tokens), - onBackButtonClick = router::pop, - endButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_plus_24, - onClicked = ::navigateToAddCustomToken, - ), - ), + topBar = getTopBarInitialState(), search = SearchBarUM( placeholderText = resourceReference(R.string.common_search), query = "", @@ -174,6 +188,20 @@ internal class ManageTokensModel @Inject constructor( .launchIn(modelScope) } + private fun checkIsSupportAddCustomTokens() { + when (val mode = params.mode) { + is ManageTokensMode.Account -> modelScope.launch { + val mainAccount = singleAccountStatusListSupplier(mode.accountId.userWalletId).first().mainAccount + if (mode.accountId == mainAccount.account.accountId) { + state.update { it.copySealed(topBar = manageContentTopBar()) } + } + } + ManageTokensMode.None, + is ManageTokensMode.Wallet, + -> Unit // use init state + } + } + private fun updateItems(items: ImmutableList) { val updatedState = state.updateAndGet { state -> state.copySealed( diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt index e7426ae87c..2b20edf084 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt @@ -57,6 +57,7 @@ import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent import com.tangem.features.managetokens.entity.item.CurrencyItemUM @@ -442,20 +443,23 @@ private class PreviewManageTokensComponentProvider : PreviewParameterProvider + val network = params.allAvailable + .find { it.networkId == row.id } + ?: return@onNetworkClick + params.callbacks.onNetworkSelected(network) + }, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + ChooseNetworkContent(state) + } + + data class Params( + val alreadyAdded: Set, + val allAvailable: List, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onNetworkSelected(network: TokenMarketInfo.Network) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/ChooseNetworkContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/ChooseNetworkContent.kt new file mode 100644 index 0000000000..e2f26ae35b --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/ChooseNetworkContent.kt @@ -0,0 +1,110 @@ +package com.tangem.features.markets.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +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.util.fastForEachIndexed +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.BlockchainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +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.markets.impl.R +import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +private const val DISABLED_ALPHA = 0.4f + +@Composable +internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.networks.fastForEachIndexed { index, model -> + key(model.id) { + BlockchainRow( + model = model, + itemPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing14, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }), + ) { + if (!model.isEnabled) { + Label( + modifier = Modifier.alpha(DISABLED_ALPHA), + state = LabelUM( + text = resourceReference(R.string.common_added), + style = LabelStyle.REGULAR, + ), + ) + } + } + } + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreview { + ChooseNetworkContent( + state = content, + ) + } +} + +internal class ChooseNetworkContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = UUID.randomUUID().toString(), + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.img_eth_22, + isMainNetwork = false, + isSelected = true, + isEnabled = true, + ) + + override val values: Sequence + get() = sequenceOf( + ChooseNetworkUM( + onNetworkClick = {}, + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + ), + blockchainRow.copy( + iconResId = R.drawable.ic_bsc_16, + isEnabled = false, + ), + blockchainRow.copy(iconResId = R.drawable.img_polygon_22), + blockchainRow.copy(iconResId = R.drawable.img_optimism_22), + ), + ), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/ChooseNetworkUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/ChooseNetworkUM.kt new file mode 100644 index 0000000000..8e27218757 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/ChooseNetworkUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.markets.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +data class ChooseNetworkUM( + val networks: ImmutableList, + val onNetworkClick: (BlockchainRowUM) -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 8280707bae..e1d7b97469 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.settings) implementation(projects.domain.transaction.models) + implementation(projects.domain.account.status) /** DI */ implementation(deps.hilt.android) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt index f4fe8672a8..644770a58b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.selecttoken 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 @@ -41,11 +42,13 @@ internal class DefaultOnrampOperationComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val state = model.state.collectAsStateWithLifecycle() + val state by model.state.collectAsStateWithLifecycle() + val onrampTokenListState by onrampTokenListComponent.uiState.collectAsStateWithLifecycle() OnrampSelectToken( - state = state.value, + state = state, onrampTokenListComponent = onrampTokenListComponent, + onrampTokenListState = onrampTokenListState, hotCryptoComponent = hotCryptoComponent, modifier = modifier, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt index 689cee2068..43ac75ccc7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt @@ -21,12 +21,14 @@ import com.tangem.features.onramp.hottokens.HotCryptoComponent import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.selecttoken.entity.OnrampOperationUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent +import com.tangem.features.onramp.tokenlist.entity.TokenListUM @OptIn(ExperimentalFoundationApi::class) @Composable internal fun OnrampSelectToken( state: OnrampOperationUM, onrampTokenListComponent: OnrampTokenListComponent, + onrampTokenListState: TokenListUM, hotCryptoComponent: HotCryptoComponent?, modifier: Modifier = Modifier, ) { @@ -50,8 +52,9 @@ internal fun OnrampSelectToken( ) } - item(key = "token_list", contentType = "token_list") { - onrampTokenListComponent.Content( + with(onrampTokenListComponent) { + content( + uiState = onrampTokenListState, modifier = Modifier .padding(top = 8.dp) .padding(horizontal = 16.dp) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt index 0f8001df67..5040c07c57 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt @@ -2,6 +2,7 @@ package com.tangem.features.onramp.swap import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -54,12 +55,16 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val state = model.state.collectAsStateWithLifecycle() + val state by model.state.collectAsStateWithLifecycle() + val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle() + val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle() SwapSelectTokens( - state = state.value, + state = state, selectFromTokenListComponent = selectFromTokenListComponent, + selectFromTokenListState = fromTokensState, selectToTokenListComponent = selectToTokenListComponent, + selectToTokenListState = toTokensState, modifier = modifier, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt index 35f44bbb33..cb87a50dc2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -3,14 +3,15 @@ package com.tangem.features.onramp.swap.availablepairs import androidx.compose.runtime.Stable import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.onramp.tokenlist.entity.TokenListUM import kotlinx.coroutines.flow.StateFlow /** Token list component that present list of available tokens for swap */ @Stable -internal interface AvailableSwapPairsComponent : ComposableContentComponent { +internal interface AvailableSwapPairsComponent : ComposableListContentComponent { /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index fd77ee062f..eea5740b23 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -1,17 +1,17 @@ package com.tangem.features.onramp.swap.availablepairs -import androidx.compose.runtime.Composable +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Stable -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.onramp.swap.availablepairs.model.AvailableSwapPairsModel -import com.tangem.features.onramp.tokenlist.ui.TokenList +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.ui.onrampTokenList import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.StateFlow @Stable internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( @@ -21,11 +21,11 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( private val model: AvailableSwapPairsModel = getOrCreateModel(params) - @Composable - override fun Content(modifier: Modifier) { - val state by model.state.collectAsStateWithLifecycle() + override val uiState: StateFlow + get() = model.state - TokenList(state = state, modifier = modifier) + override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) { + onrampTokenList(state = uiState) } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt new file mode 100644 index 0000000000..9489858928 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.features.onramp.swap.availablepairs.entity.converters + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList + +internal class LoadingAccountTokenItemConverter( + private val appCurrency: AppCurrency, +) : Converter { + + override fun convert(value: AccountStatus.CryptoPortfolio): TokensListItemUM.Portfolio { + val (account, currencies) = value + + return TokensListItemUM.Portfolio( + tokenItemUM = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = account, + onItemClick = null, + ).convert(TotalFiatBalance.Failed), + isExpanded = true, + isCollapsable = false, + tokens = currencies.flattenCurrencies().map(LoadingTokenListItemConverter::convert).toPersistentList(), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt new file mode 100644 index 0000000000..9ee61d4ba9 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt @@ -0,0 +1,61 @@ +package com.tangem.features.onramp.swap.availablepairs.entity.transformers + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus +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.toPersistentList + +internal class SetNoAvailablePairsTransformerV2( + private val appCurrency: AppCurrency, + private val accountList: Map>, + private val isBalanceHidden: Boolean, + private val isAccountsMode: Boolean, + private val unavailableErrorText: TextReference, +) : TokenListUMTransformer { + private val unavailableConverter = OnrampTokenItemStateConverterFactory + .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = if (isAccountsMode) { + TokenListUMData.AccountList( + tokensList = accountList.map { (account, cryptoCurrencies) -> + TokensListItemUM.Portfolio( + tokenItemUM = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = account, + onItemClick = null, + ).convert(TotalFiatBalance.Failed), + isExpanded = true, + isCollapsable = false, + tokens = unavailableConverter.convertList(cryptoCurrencies) + .map(TokensListItemUM::Token) + .toPersistentList(), + ) + }.toPersistentList(), + ) + } else { + TokenListUMData.TokenList( + tokensList = accountList.flatMap { (_, cryptoCurrencies) -> + unavailableConverter.convertList(cryptoCurrencies) + .map(TokensListItemUM::Token) + }.toPersistentList(), + ) + }, + isBalanceHidden = isBalanceHidden, + warning = NotificationUM.Warning.SwapNoAvailablePair, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index d62cce58ae..8e19216bf3 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -1,12 +1,15 @@ package com.tangem.features.onramp.swap.availablepairs.model -import arrow.core.getOrElse import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.extensions.capitalize import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +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.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -15,6 +18,8 @@ import com.tangem.domain.core.utils.getOrElse 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.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.tokenlist.TokenList @@ -28,11 +33,13 @@ import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponen import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetLoadingTokenItemsTransformer import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformer +import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformerV2 +import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.swap.entity.AccountCurrencyUM import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMController import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer +import com.tangem.features.onramp.tokenlist.entity.transformer.* import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer @@ -43,7 +50,7 @@ import javax.inject.Inject private typealias AvailablePairsState = Lce> -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class AvailableSwapPairsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, @@ -53,7 +60,10 @@ internal class AvailableSwapPairsModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getAvailablePairsUseCase: GetAvailablePairsUseCase, - private val getWalletsUseCase: GetWalletsUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + getWalletsUseCase: GetWalletsUseCase, ) : Model() { val state: StateFlow = tokenListUMController.state @@ -62,13 +72,17 @@ internal class AvailableSwapPairsModel @Inject constructor( private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val tokenListFlow = getTokenListUseCaseFlow() - + private val accountListFlow = getAccountListUseCaseFlow() private val availablePairsByNetworkFlow = MutableStateFlow>(emptyMap()) init { - initializeSearchBarCallbacks() + if (accountsFeatureToggles.isFeatureEnabled) { + subscribeOnUpdateStateV2() + } else { + subscribeOnUpdateState() + } - subscribeOnUpdateState() + initializeSearchBarCallbacks() subscribeOnAvailablePairsUpdates() } @@ -79,12 +93,20 @@ internal class AvailableSwapPairsModel @Inject constructor( maybeTokenList.getOrElse( ifLoading = { it ?: TokenList.Empty }, ifError = { TokenList.Empty }, - ) - .flattenCurrencies() + ).flattenCurrencies() } .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) } + private fun getAccountListUseCaseFlow(): SharedFlow> { + return singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(params.userWalletId)) + .distinctUntilChanged() + .map { accountStatusList -> + accountStatusList.accountStatuses.toList() + }.flowOn(dispatchers.default) + .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) + } + private fun initializeSearchBarCallbacks() { tokenListUMController.update( transformer = UpdateSearchBarCallbacksTransformer( @@ -130,6 +152,53 @@ internal class AvailableSwapPairsModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeOnUpdateStateV2() { + combine( + flow = getAccountsAndModeFlow(), + flow2 = getAppCurrencyAndBalanceHidingFlow(), + flow3 = params.selectedStatus, + flow4 = searchManager.query, + flow5 = availablePairsByNetworkFlow + .map { it[params.selectedStatus.value?.toLeastTokenInfo()] } + .distinctUntilChanged(), + ) { accountListAndMode, appCurrencyAndBalanceHiding, selectedStatus, query, availablePairsState -> + val (accountList, isAccountsMode) = accountListAndMode + availablePairsState?.fold( + ifLoading = { + SetLoadingAccountTokenListTransformer( + appCurrency = appCurrencyAndBalanceHiding.first, + accountList = accountList, + isAccountsMode = isAccountsMode, + ) + }, + ifContent = { pairs -> + handleContentStateV2( + appCurrencyAndBalanceHiding = appCurrencyAndBalanceHiding, + accountList = accountList, + selectedStatus = selectedStatus, + query = query, + availablePairs = pairs, + isAccountsMode = isAccountsMode, + ) + }, + ifError = { + handleErrorStateV2( + cause = it, + networkInfo = params.selectedStatus.value?.toLeastTokenInfo(), + accountList = accountList, + ) + }, + ) ?: SetLoadingAccountTokenListTransformer( + appCurrency = appCurrencyAndBalanceHiding.first, + accountList = accountList, + isAccountsMode = isAccountsMode, + ) + } + .onEach(tokenListUMController::update) + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + private fun handleContentState( appCurrencyAndBalanceHiding: Pair, currencies: List, @@ -176,6 +245,53 @@ internal class AvailableSwapPairsModel @Inject constructor( } } + private fun handleContentStateV2( + appCurrencyAndBalanceHiding: Pair, + accountList: List, + selectedStatus: CryptoCurrencyStatus?, + query: String, + availablePairs: List, + isAccountsMode: Boolean, + ): TokenListUMTransformer { + val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding + + val filterByQueryAccountList = accountList.associate { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account to accountStatus.tokenList.flattenCurrencies() + .filter { it.currency != selectedStatus?.currency } + .filterByQuery(query = query) + } + } + + if (availablePairs.isEmpty()) { + return SetNoAvailablePairsTransformerV2( + appCurrency = appCurrency, + accountList = filterByQueryAccountList, + unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), + isBalanceHidden = isBalanceHidden, + isAccountsMode = isAccountsMode, + ) + } + + return if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) { + SetNothingToFoundStateTransformerV2( + isBalanceHidden = isBalanceHidden, + emptySearchMessageReference = resourceReference( + id = R.string.action_buttons_swap_empty_search_message, + ), + ) + } else { + UpdateAccountTokenListTransformer( + appCurrency = appCurrency, + onItemClick = params.onTokenClick, + accountList = filterByQueryAccountList.filterByAvailability(availablePairs = availablePairs), + isBalanceHidden = isBalanceHidden, + unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), + isAccountsMode = isAccountsMode, + ) + } + } + private fun handleErrorState( cause: Throwable, networkInfo: LeastTokenInfo?, @@ -193,6 +309,26 @@ internal class AvailableSwapPairsModel @Inject constructor( ) } + private fun handleErrorStateV2( + cause: Throwable, + networkInfo: LeastTokenInfo?, + accountList: List, + ): SetErrorWarningTransformer { + return SetErrorWarningTransformer( + cause = cause, + onRefresh = { + modelScope.launch { + if (networkInfo != null) { + accountList.filterIsInstance() + .forEach { (_, currencies) -> + updateAvailablePairs(networkInfo, currencies.flattenCurrencies()) + } + } + } + }, + ) + } + private fun subscribeOnAvailablePairsUpdates() { modelScope.launch { params.selectedStatus @@ -203,9 +339,19 @@ internal class AvailableSwapPairsModel @Inject constructor( val isAlreadyLoaded = availablePairsByNetworkFlow.value[networkInfo]?.isContent() == true if (isAlreadyLoaded) return@collectLatest - val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest - - updateAvailablePairs(networkInfo = networkInfo, statuses = statuses) + if (accountsFeatureToggles.isFeatureEnabled) { + val accountList = accountListFlow.firstOrNull() ?: return@collectLatest + updateAvailablePairs( + networkInfo = networkInfo, + statuses = accountList.filterIsInstance() + .flatMap { accountStatus -> + accountStatus.flattenCurrencies() + }.toSet().toList(), + ) + } else { + val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest + updateAvailablePairs(networkInfo = networkInfo, statuses = statuses) + } } } } @@ -247,6 +393,14 @@ internal class AvailableSwapPairsModel @Inject constructor( ) } + private fun getAccountsAndModeFlow(): Flow, Boolean>> { + return combine( + flow = accountListFlow.distinctUntilChanged(), + flow2 = isAccountsModeEnabledUseCase().distinctUntilChanged(), + transform = ::Pair, + ) + } + private fun onSearchQueryChange(newQuery: String) { if (state.value.searchBarUM.query == newQuery) return @@ -286,6 +440,29 @@ internal class AvailableSwapPairsModel @Inject constructor( } } + private fun Map>.filterByAvailability( + availablePairs: List, + ): List { + return map { (account, currencies) -> + AccountAvailabilityUM( + account = account, + currencyList = currencies.map { status -> + val isAvailable = availablePairs.map(SwapPairLeast::to).contains(status.toLeastTokenInfo()) + + val isAvailableToSwap = isAvailable && + status.value !is CryptoCurrencyStatus.MissedDerivation && + status.value !is CryptoCurrencyStatus.Unreachable && + !status.currency.isCustom + + AccountCurrencyUM( + cryptoCurrencyStatus = status, + isAvailable = isAvailableToSwap, + ) + }, + ) + } + } + private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo { return LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt new file mode 100644 index 0000000000..f0856222ba --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.onramp.swap.entity + +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus + +internal data class AccountAvailabilityUM( + val account: Account.CryptoPortfolio, + val currencyList: List, +) + +internal data class AccountCurrencyUM( + val isAvailable: Boolean, + val cryptoCurrencyStatus: CryptoCurrencyStatus, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt index 817e7218ba..0b159c1423 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt @@ -1,5 +1,7 @@ package com.tangem.features.onramp.swap.entity +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference @@ -11,7 +13,7 @@ import com.tangem.core.ui.extensions.TextReference internal sealed interface ExchangeCardUM { /** Title reference */ - val titleReference: TextReference + val titleUM: TitleUM /** Remove button UI model */ val removeButtonUM: RemoveButtonUM? @@ -19,11 +21,11 @@ internal sealed interface ExchangeCardUM { /** * Empty state * - * @property titleReference title reference + * @property titleUM title reference * @property subtitleReference empty token subtitle reference */ data class Empty( - override val titleReference: TextReference, + override val titleUM: TitleUM, val subtitleReference: TextReference, ) : ExchangeCardUM { @@ -33,15 +35,29 @@ internal sealed interface ExchangeCardUM { /** * Filled * - * @property titleReference title reference + * @property titleUM title reference * @property removeButtonUM remove button UI model * @property tokenItemState token item state */ data class Filled( - override val titleReference: TextReference, + override val titleUM: TitleUM, override val removeButtonUM: RemoveButtonUM?, val tokenItemState: TokenItemState, ) : ExchangeCardUM data class RemoveButtonUM(val onClick: () -> Unit) + + @Immutable + sealed interface TitleUM { + + data class Text( + val title: TextReference, + ) : TitleUM + + data class Account( + val prefixText: TextReference, + val name: TextReference, + val icon: CryptoPortfolioIconUM, + ) : TitleUM + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt index 08c088f59e..3ca1f8776b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.swap.entity.transformer import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.models.account.Account import com.tangem.features.onramp.swap.entity.ExchangeCardUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer @@ -17,6 +18,8 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled internal class SelectFromTokenTransformer( private val selectedTokenItemState: TokenItemState, private val onRemoveClick: () -> Unit, + private val account: Account.CryptoPortfolio?, + private val isAccountsMode: Boolean, ) : SwapSelectTokensUMTransformer { override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { @@ -24,6 +27,9 @@ internal class SelectFromTokenTransformer( exchangeFrom = prevState.exchangeFrom.toFilled( selectedTokenItemState = selectedTokenItemState, removeButtonUM = ExchangeCardUM.RemoveButtonUM(onClick = onRemoveClick), + account = account, + isAccountsMode = isAccountsMode, + isFromCurrency = true, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt index 433b28f5ff..8699cd9e56 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.swap.entity.transformer import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.models.account.Account import com.tangem.features.onramp.swap.entity.ExchangeCardUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer @@ -15,12 +16,19 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled */ internal class SelectToTokenTransformer( private val selectedTokenItemState: TokenItemState, + private val isAccountsMode: Boolean, + private val account: Account.CryptoPortfolio?, ) : SwapSelectTokensUMTransformer { override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { return prevState.copy( exchangeFrom = prevState.exchangeFrom.hideRemoveButton(), - exchangeTo = prevState.exchangeTo.toFilled(selectedTokenItemState = selectedTokenItemState), + exchangeTo = prevState.exchangeTo.toFilled( + selectedTokenItemState = selectedTokenItemState, + isAccountsMode = isAccountsMode, + account = account, + isFromCurrency = false, + ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt index 6daf403437..757945de5a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt @@ -1,14 +1,16 @@ package com.tangem.features.onramp.swap.entity.utils +import com.tangem.common.ui.account.toUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.Account import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.entity.ExchangeCardUM /** Create empty exchange "from" card */ internal fun createEmptyExchangeFrom(): ExchangeCardUM.Empty { return ExchangeCardUM.Empty( - titleReference = resourceReference(id = R.string.swapping_from_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap), ) } @@ -16,7 +18,7 @@ internal fun createEmptyExchangeFrom(): ExchangeCardUM.Empty { /** Create empty exchange "to" card */ internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty { return ExchangeCardUM.Empty( - titleReference = resourceReference(id = R.string.swapping_to_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_to_title)), subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_receive), ) } @@ -29,10 +31,25 @@ internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty { */ internal fun ExchangeCardUM.toFilled( selectedTokenItemState: TokenItemState, + account: Account.CryptoPortfolio?, + isAccountsMode: Boolean, + isFromCurrency: Boolean, removeButtonUM: ExchangeCardUM.RemoveButtonUM? = null, ): ExchangeCardUM.Filled { return ExchangeCardUM.Filled( - titleReference = titleReference, + titleUM = if (account != null && isAccountsMode) { + ExchangeCardUM.TitleUM.Account( + prefixText = if (isFromCurrency) { + resourceReference(R.string.common_from) + } else { + resourceReference(R.string.common_to) + }, + name = account.accountName.toUM().value, + icon = account.icon.toUM(), + ) + } else { + titleUM + }, tokenItemState = selectedTokenItemState, removeButtonUM = removeButtonUM, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index 5472e0e61c..5d12338467 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -8,7 +8,10 @@ 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.ui.components.token.state.TokenItemState +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.onramp.component.SwapSelectTokensComponent import com.tangem.features.onramp.swap.entity.SwapSelectTokensController @@ -32,6 +35,8 @@ internal class SwapSelectTokensModel @Inject constructor( private val router: Router, private val analyticsEventHandler: AnalyticsEventHandler, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, ) : Model() { val state: StateFlow = controller.state @@ -43,9 +48,13 @@ internal class SwapSelectTokensModel @Inject constructor( private val params = paramsContainer.require() + private var isAccountsMode: Boolean = false + private var account: Account.CryptoPortfolio? = null + init { controller.update { it.copy(onBackClick = ::onBackClick) } + subscribeOnAccountsMode() subscribeOnBalanceHidingSettings() } @@ -62,12 +71,19 @@ internal class SwapSelectTokensModel @Inject constructor( _fromCurrencyStatus.value = status - controller.update( - transformer = SelectFromTokenTransformer( - selectedTokenItemState = selectedTokenItemState, - onRemoveClick = ::onRemoveFromTokenClick, - ), - ) + modelScope.launch { + controller.update( + transformer = SelectFromTokenTransformer( + selectedTokenItemState = selectedTokenItemState, + onRemoveClick = ::onRemoveFromTokenClick, + isAccountsMode = isAccountsMode, + account = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = params.userWalletId, + currency = status.currency, + ).getOrNull()?.account, + ), + ) + } } /** @@ -84,7 +100,16 @@ internal class SwapSelectTokensModel @Inject constructor( modelScope.launch { _toCurrencyStatus.value = status - controller.update(transformer = SelectToTokenTransformer(selectedTokenItemState)) + controller.update( + transformer = SelectToTokenTransformer( + selectedTokenItemState = selectedTokenItemState, + isAccountsMode = isAccountsMode, + account = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = params.userWalletId, + currency = status.currency, + ).getOrNull()?.account, + ), + ) // require some delay to show state with selected "from" and "to" tokens delay(timeMillis = 500) @@ -119,6 +144,16 @@ internal class SwapSelectTokensModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeOnAccountsMode() { + isAccountsModeEnabledUseCase() + .distinctUntilChanged() + .onEach { + isAccountsMode = it + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + private fun onBackClick() { analyticsEventHandler.send( event = MainScreenAnalyticsEvent.ButtonClose(source = AnalyticsParam.ScreensSources.Swap), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt index da3b5ead84..4ee9b3eb0e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt @@ -14,11 +14,14 @@ 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.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.account.AccountLabel +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.rows.NetworkTitle @@ -46,13 +49,14 @@ internal fun ExchangeCard(state: ExchangeCardUM, isBalanceHidden: Boolean, modif modifier = modifier .fillMaxWidth() .heightIn(min = 116.dp) - .background( - color = TangemTheme.colors.background.primary, - shape = TangemTheme.shapes.roundedCornersXMedium, - ), + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary), verticalArrangement = Arrangement.SpaceBetween, ) { - Title(titleReference = state.titleReference, removeButtonUM = state.removeButtonUM) + Title( + titleUM = state.titleUM, + removeButtonUM = state.removeButtonUM, + ) AnimatedContent( targetState = state, @@ -73,16 +77,39 @@ internal fun ExchangeCard(state: ExchangeCardUM, isBalanceHidden: Boolean, modif } @Composable -private fun Title(titleReference: TextReference, removeButtonUM: ExchangeCardUM.RemoveButtonUM?) { +private fun Title(titleUM: ExchangeCardUM.TitleUM, removeButtonUM: ExchangeCardUM.RemoveButtonUM?) { NetworkTitle( title = { - Text( - text = titleReference.resolveReference(), - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TangemTheme.typography.subtitle2, - ) + AnimatedContent( + titleUM, + ) { currentState -> + when (currentState) { + is ExchangeCardUM.TitleUM.Account -> Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = currentState.prefixText.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + AccountLabel( + name = currentState.name, + icon = currentState.icon, + iconSize = AccountIconSize.ExtraSmall, + nameStyle = TangemTheme.typography.subtitle2, + nameColor = TangemTheme.colors.text.tertiary, + ) + } + is ExchangeCardUM.TitleUM.Text -> Text( + text = currentState.title.resolveReference(), + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography.subtitle2, + ) + } + } }, action = { RemoveButton(state = removeButtonUM) }, ) @@ -153,7 +180,7 @@ private class ExchangeCardUMProvider : PreviewParameterProvider override val values: Sequence = sequenceOf( ExchangeCardUM.Empty( - titleReference = resourceReference(id = R.string.swapping_from_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap), ), createFilled(removeButtonUM = null), @@ -162,7 +189,7 @@ private class ExchangeCardUMProvider : PreviewParameterProvider private fun createFilled(removeButtonUM: ExchangeCardUM.RemoveButtonUM?): ExchangeCardUM.Filled { return ExchangeCardUM.Filled( - titleReference = resourceReference(id = R.string.swapping_from_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), removeButtonUM = removeButtonUM, tokenItemState = TokenItemState.Content( id = "1", diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt index bf873aa6f5..346d2b498f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -23,6 +24,7 @@ import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponen import com.tangem.features.onramp.swap.entity.ExchangeCardUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent +import com.tangem.features.onramp.tokenlist.entity.TokenListUM /** * Swap select tokens @@ -39,7 +41,9 @@ import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent internal fun SwapSelectTokens( state: SwapSelectTokensUM, selectFromTokenListComponent: OnrampTokenListComponent, + selectFromTokenListState: TokenListUM, selectToTokenListComponent: AvailableSwapPairsComponent, + selectToTokenListState: TokenListUM, modifier: Modifier = Modifier, ) { BackHandler(onBack = state.onBackClick) @@ -77,33 +81,33 @@ internal fun SwapSelectTokens( } if (state.exchangeFrom is ExchangeCardUM.Empty) { - item(key = "select_from", contentType = "select_from") { - selectFromTokenListComponent.Content( - modifier = Modifier - .padding(horizontal = 16.dp) - .animateItem(), + with(selectFromTokenListComponent) { + content( + uiState = selectFromTokenListState, + modifier = Modifier, ) } } if (state.exchangeFrom is ExchangeCardUM.Filled) { item(key = "exchange_to", contentType = "exchange_to") { - ExchangeCard( - state = state.exchangeTo, - isBalanceHidden = state.isBalanceHidden, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .animateItem(), - ) + if (selectToTokenListState.warning != NotificationUM.Warning.SwapNoAvailablePair) { + ExchangeCard( + state = state.exchangeTo, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + .animateItem(), + ) + } } if (state.exchangeTo is ExchangeCardUM.Empty) { - item(key = "select_to", contentType = "select_to") { - selectToTokenListComponent.Content( - modifier = Modifier - .padding(horizontal = 16.dp) - .animateItem(), + with(selectToTokenListComponent) { + content( + uiState = selectToTokenListState, + modifier = Modifier.padding(horizontal = 16.dp), ) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt index c538d475cd..d35442f63a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt @@ -1,17 +1,17 @@ package com.tangem.features.onramp.tokenlist -import androidx.compose.runtime.Composable +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Stable -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.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.model.OnrampTokenListModel -import com.tangem.features.onramp.tokenlist.ui.TokenList +import com.tangem.features.onramp.tokenlist.ui.onrampTokenList import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.StateFlow @Stable internal class DefaultOnrampTokenListComponent @AssistedInject constructor( @@ -21,11 +21,11 @@ internal class DefaultOnrampTokenListComponent @AssistedInject constructor( private val model: OnrampTokenListModel = getOrCreateModel(params) - @Composable - override fun Content(modifier: Modifier) { - val state by model.state.collectAsStateWithLifecycle() + override val uiState: StateFlow + get() = model.state - TokenList(state = state, modifier = modifier) + override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) { + onrampTokenList(state = uiState) } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt index 94ba06e247..4490a37ec3 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt @@ -3,14 +3,15 @@ package com.tangem.features.onramp.tokenlist import androidx.compose.runtime.Stable import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.onramp.tokenlist.entity.OnrampOperation +import com.tangem.features.onramp.tokenlist.entity.TokenListUM /** Token list component that present list of token for multi-currency wallet */ @Stable -internal interface OnrampTokenListComponent : ComposableContentComponent { +internal interface OnrampTokenListComponent : ComposableListContentComponent { /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt index 615823f4e4..3f88743a05 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt @@ -19,6 +19,19 @@ internal data class TokenListUM( val searchBarUM: SearchBarUM, val availableItems: ImmutableList, val unavailableItems: ImmutableList, + val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, val warning: NotificationUM? = null, -) \ No newline at end of file +) + +internal sealed interface TokenListUMData { + data class AccountList( + val tokensList: ImmutableList, + ) : TokenListUMData + + data class TokenList( + val tokensList: ImmutableList, + ) : TokenListUMData + + data object EmptyList : TokenListUMData +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt index 80bb1d02f0..583697cca2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt @@ -31,6 +31,7 @@ internal class TokenListUMController @Inject constructor() { ), availableItems = persistentListOf(), unavailableItems = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, isBalanceHidden = false, ), ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt new file mode 100644 index 0000000000..48d7b7331d --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt @@ -0,0 +1,45 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingAccountTokenItemConverter +import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingTokenListItemConverter +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData +import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class SetLoadingAccountTokenListTransformer( + appCurrency: AppCurrency, + private val accountList: List, + private val isAccountsMode: Boolean, +) : TokenListUMTransformer { + + private val accountListItemConverter = LoadingAccountTokenItemConverter(appCurrency) + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = if (isAccountsMode) { + TokenListUMData.AccountList( + tokensList = accountListItemConverter.convertList( + accountList.filterIsInstance(), + ).toPersistentList(), + ) + } else { + TokenListUMData.TokenList( + tokensList = accountList.flatMap { account -> + when (account) { + is AccountStatus.CryptoPortfolio -> LoadingTokenListItemConverter.convertList( + account.tokenList.flattenCurrencies(), + ) + } + }.toPersistentList(), + ) + }, + warning = null, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt index e98b29bfaa..fcaa9d6147 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -26,9 +27,9 @@ internal class SetNothingToFoundStateTransformer( id = emptySearchMessageReference.hashCode(), text = emptySearchMessageReference, ).let(::add) - } - .toImmutableList(), + }.toImmutableList(), unavailableItems = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, isBalanceHidden = isBalanceHidden, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt new file mode 100644 index 0000000000..dafa2ac842 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt @@ -0,0 +1,29 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData +import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class SetNothingToFoundStateTransformerV2( + private val isBalanceHidden: Boolean, + private val emptySearchMessageReference: TextReference, +) : TokenListUMTransformer { + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = TokenListUMData.TokenList(tokensList = buildList { + TokensListItemUM.Text( + id = emptySearchMessageReference.hashCode(), + text = emptySearchMessageReference, + ).let(::add) + }.toImmutableList()), + isBalanceHidden = isBalanceHidden, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt new file mode 100644 index 0000000000..f90f1e9794 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt @@ -0,0 +1,45 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +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.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList + +internal class UpdateAccountTokenItemConverter( + private val appCurrency: AppCurrency, + private val unavailableErrorText: TextReference, + onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit, +) : Converter { + + private val availableConverter = OnrampTokenItemStateConverterFactory + .createAvailableItemConverter(appCurrency, onItemClick) + + private val unavailableConverter = OnrampTokenItemStateConverterFactory + .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) + + override fun convert(value: AccountAvailabilityUM): TokensListItemUM.Portfolio { + return TokensListItemUM.Portfolio( + tokenItemUM = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = value.account, + onItemClick = null, + ).convert(TotalFiatBalance.Failed), + isExpanded = true, + isCollapsable = false, + tokens = value.currencyList.asSequence().map { (isAvailable, status) -> + if (isAvailable) { + availableConverter.convert(status) + } else { + unavailableConverter.convert(status) + } + }.map(TokensListItemUM::Token).toPersistentList(), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..5b13ba643c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -0,0 +1,64 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +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.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +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.toPersistentList + +internal class UpdateAccountTokenListTransformer( + private val appCurrency: AppCurrency, + private val onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit, + private val accountList: List, + private val isBalanceHidden: Boolean, + private val unavailableErrorText: TextReference, + private val warning: NotificationUM? = null, + private val isAccountsMode: Boolean, +) : TokenListUMTransformer { + + private val accountListItemConverter = UpdateAccountTokenItemConverter( + appCurrency = appCurrency, + onItemClick = onItemClick, + unavailableErrorText = unavailableErrorText, + ) + + private val availableConverter = OnrampTokenItemStateConverterFactory + .createAvailableItemConverter(appCurrency, onItemClick) + + private val unavailableConverter = OnrampTokenItemStateConverterFactory + .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = if (isAccountsMode) { + TokenListUMData.AccountList( + tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), + ) + } else { + TokenListUMData.TokenList( + tokensList = accountList.flatMap { (_, currencyList) -> + currencyList.asSequence().map { (isAvailable, status) -> + if (isAvailable) { + availableConverter.convert(status) + } else { + unavailableConverter.convert(status) + } + }.map(TokensListItemUM::Token).toPersistentList() + }.toPersistentList(), + ) + }, + isBalanceHidden = isBalanceHidden, + warning = warning, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt index f693f02e79..67f0c13e19 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt @@ -6,6 +6,7 @@ import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormatte 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.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -21,7 +22,13 @@ internal object OnrampTokenItemStateConverterFactory { ): TokenItemStateConverter { return TokenItemStateConverter( appCurrency = appCurrency, - subtitleStateProvider = { createSubtitleState(status = it, isAvailable = true) }, + subtitleStateProvider = { + createSubtitleState( + status = it, + isAvailable = true, + text = stringReference(value = it.currency.symbol), + ) + }, subtitle2StateProvider = ::createSubtitle2State, fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true) @@ -40,7 +47,13 @@ internal object OnrampTokenItemStateConverterFactory { isAvailable = false, ) }, - subtitleStateProvider = { createSubtitleState(status = it, isAvailable = false) }, + subtitleStateProvider = { + createSubtitleState( + status = it, + text = stringReference(value = it.currency.symbol), + isAvailable = false, + ) + }, subtitle2StateProvider = ::createSubtitle2State, fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false) @@ -48,12 +61,43 @@ internal object OnrampTokenItemStateConverterFactory { ) } - private fun createSubtitleState(status: CryptoCurrencyStatus, isAvailable: Boolean): TokenItemState.SubtitleState { + fun createUnavailableItemConverterV2( + appCurrency: AppCurrency, + unavailableErrorText: TextReference, + ): 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 = stringReference(value = status.currency.symbol), + value = text, isAvailable = isAvailable, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index ae9b3f2e32..a61e5837a8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -6,6 +6,11 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -13,6 +18,8 @@ import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.settings.usercountry.GetUserCountryUseCase @@ -23,13 +30,11 @@ import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.swap.entity.AccountCurrencyUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent -import com.tangem.features.onramp.tokenlist.entity.OnrampOperation -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.entity.TokenListUMController -import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer +import com.tangem.features.onramp.tokenlist.entity.* +import com.tangem.features.onramp.tokenlist.entity.transformer.* import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer @@ -42,7 +47,9 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") +typealias AccountCryptoList = Map> + +@Suppress("LargeClass", "LongParameterList") internal class OnrampTokenListModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, @@ -55,6 +62,9 @@ internal class OnrampTokenListModel @Inject constructor( private val rampStateManager: RampStateManager, private val getUserCountryUseCase: GetUserCountryUseCase, private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, ) : Model() { val state: StateFlow = tokenListUMController.state @@ -71,8 +81,11 @@ internal class OnrampTokenListModel @Inject constructor( onActiveChange = ::onSearchBarActiveChange, ), ) - - subscribeOnUpdateState() + if (accountsFeatureToggles.isFeatureEnabled) { + subscribeOnUpdateStateV2() + } else { + subscribeOnUpdateState() + } } private fun subscribeOnUpdateState() { @@ -95,12 +108,7 @@ internal class OnrampTokenListModel @Inject constructor( if (query.isNotEmpty() && filterByQueryTokenList.isEmpty()) { SetNothingToFoundStateTransformer( isBalanceHidden = isBalanceHidden, - emptySearchMessageReference = when (params.filterOperation) { - OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message - OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message - OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message - } - .let(::resourceReference), + emptySearchMessageReference = getEmptySearchMessageReference(), ) } else { val isInsufficientBalanceForSell = if (params.filterOperation == OnrampOperation.SELL) { @@ -134,6 +142,62 @@ internal class OnrampTokenListModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeOnUpdateStateV2() { + combine( + flow = singleAccountStatusListSupplier( + SingleAccountStatusListProducer.Params(params.userWalletId), + ).distinctUntilChanged(), + flow2 = getAppCurrencyAndBalanceHidingFlow(), + flow3 = isAccountsModeEnabledUseCase(), + flow4 = searchManager.query, + flow5 = hasRestrictionForSellFlow(), + ) { accountList, appCurrencyAndBalanceHiding, isAccountsMode, query, hasRestrictionForSell -> + val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding + val filterByQueryAccountList = accountList.filterAccountsByQuery(query) + + if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) { + updateTokenListUM( + SetNothingToFoundStateTransformerV2( + isBalanceHidden = isBalanceHidden, + emptySearchMessageReference = getEmptySearchMessageReference(), + ), + ) + } else { + updateTokenListUM( + SetLoadingAccountTokenListTransformer( + appCurrency = appCurrency, + accountList = accountList.accountStatuses.toList(), + isAccountsMode = isAccountsMode, + ), + ) + updateTokenListUM( + UpdateAccountTokenListTransformer( + appCurrency = appCurrency, + onItemClick = params.onTokenClick, + accountList = filterByQueryAccountList.filterByAvailability(), + isBalanceHidden = isBalanceHidden, + unavailableErrorText = getUnavailableTokensHeaderReference(), + warning = getSellWarning( + hasRestrictionForSell = hasRestrictionForSell, + isInsufficientBalanceForSell = accountList.isInsufficientBalanceForSell(), + ), + isAccountsMode = isAccountsMode, + ), + ) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun getAppCurrencyAndBalanceHidingFlow(): Flow> { + return combine( + flow = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }.distinctUntilChanged(), + flow2 = getBalanceHidingSettingsUseCase().map { it.isBalanceHidden }.distinctUntilChanged(), + transform = ::Pair, + ) + } + private fun hasRestrictionForSellFlow(): Flow { return if (params.filterOperation == OnrampOperation.SELL) { getUserCountryUseCase().map { maybe -> @@ -154,25 +218,52 @@ internal class OnrampTokenListModel @Inject constructor( } } + private fun AccountStatusList.isInsufficientBalanceForSell(): Boolean { + return if (params.filterOperation == OnrampOperation.SELL) { + (totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() == true + } else { + false + } + } + private fun getUnavailableTokensHeaderReference() = when (params.filterOperation) { OnrampOperation.BUY -> R.string.tokens_list_unavailable_to_purchase_header OnrampOperation.SELL -> R.string.tokens_list_unavailable_to_sell_header OnrampOperation.SWAP -> R.string.tokens_list_unavailable_to_swap_source_header }.let(::resourceReference) + private fun getEmptySearchMessageReference() = when (params.filterOperation) { + OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message + OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message + OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message + }.let(::resourceReference) + private fun updateTokenListUM(transformer: TokenListUMTransformer) { - tokenListUMController.update { prevState -> - transformer.transform(prevState).apply { - if (isFirstInitialization(prevState = prevState, newState = this)) { - params.onTokenListInitialized() + modelScope.launch { + tokenListUMController.update { prevState -> + transformer.transform(prevState).apply { + if (isFirstInitialization(prevState = prevState, newState = this)) { + params.onTokenListInitialized() + } } } } } + private fun getSellWarning(hasRestrictionForSell: Boolean, isInsufficientBalanceForSell: Boolean) = when { + hasRestrictionForSell -> NotificationUM.Warning.SellingRegionalRestriction + isInsufficientBalanceForSell -> NotificationUM.Warning.InsufficientBalanceForSelling + else -> null + } + private fun isFirstInitialization(prevState: TokenListUM, newState: TokenListUM): Boolean { - return prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() && - (newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty()) + return if (accountsFeatureToggles.isFeatureEnabled) { + prevState.tokensListData == TokenListUMData.EmptyList && + newState.tokensListData != TokenListUMData.EmptyList + } else { + prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() && + (newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty()) + } } private fun onSearchQueryChange(newQuery: String) { @@ -195,6 +286,16 @@ internal class OnrampTokenListModel @Inject constructor( ) } + private fun AccountStatusList.filterAccountsByQuery(query: String) = accountStatuses.asSequence() + .associate { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> { + val filteredList = accountStatus.tokenList.flattenCurrencies().filterByQuery(query = query) + accountStatus.account to filteredList + } + } + }.filter { (_, value) -> value.isNotEmpty() } + private fun List.filterByQuery(query: String): List { return filter { it.currency.name.contains(other = query, ignoreCase = true) || @@ -237,6 +338,49 @@ internal class OnrampTokenListModel @Inject constructor( } } + private suspend fun AccountCryptoList.filterByAvailability(): List { + return coroutineScope { + map { (account, currencies) -> + async { + AccountAvailabilityUM( + account = account, + currencyList = currencies.map { status -> + val isOperationAvailable = checkAvailabilityByOperation(status = status) + val isNotMissedDerivation = status.value !is CryptoCurrencyStatus.MissedDerivation + val isNotLoading = status.value !is CryptoCurrencyStatus.Loading + + val requirements = getAssetRequirementsUseCase( + userWalletId = userWallet.walletId, + currency = status.currency, + ).getOrNull() + + val isAvailableForBuy = rampStateManager.checkAssetRequirements(requirements) + val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable + + val isAvailable = when (params.filterOperation) { + OnrampOperation.BUY -> { + isAvailableForBuy + } // unreachable state is available for Buy operation + OnrampOperation.SELL -> isNotUnreachable + OnrampOperation.SWAP -> { + isNotUnreachable && isAvailableForBuy + } + } + + val isTotalAvailable = + isOperationAvailable && isNotMissedDerivation && isNotLoading && isAvailable + + AccountCurrencyUM( + cryptoCurrencyStatus = status, + isAvailable = isTotalAvailable, + ) + }, + ) + } + }.awaitAll() + } + } + private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean { return when (params.filterOperation) { OnrampOperation.BUY -> { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index 27943a21c4..aefd164597 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -3,32 +3,34 @@ package com.tangem.features.onramp.tokenlist.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics 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.notifications.NotificationUM -import com.tangem.core.ui.components.SpacerH12 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.Notification +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.conditional 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.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.ui.preview.PreviewTokenListUMProvider import kotlinx.collections.immutable.ImmutableList @@ -36,17 +38,21 @@ import kotlinx.collections.immutable.ImmutableList * Token list * * @param state state - * @param modifier modifier * [REDACTED_AUTHOR] */ -@Composable -internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) { - Column(modifier) { - if (state.warning == null) { - SearchBar(searchBarUM = state.searchBarUM) - } else { - AnimatedContent(targetState = state.warning, label = "") { warning -> +internal fun LazyListScope.onrampTokenList(state: TokenListUM) { + val itemModifier = Modifier.padding(horizontal = 16.dp) + + if (state.warning == null) { + searchBarItem(searchBarUM = state.searchBarUM, modifier = itemModifier) + } else { + item("NotificationsKey") { + AnimatedContent( + targetState = state.warning, + label = "", + modifier = itemModifier, + ) { warning -> when (warning) { is NotificationUM.Warning.OnrampErrorNotification -> { Notification( @@ -60,31 +66,45 @@ internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) { } } } + } - if (state.availableItems.isNotEmpty()) { - SpacerH12() - ItemsBlock(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) - } + tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) - if (state.unavailableItems.isNotEmpty()) { - SpacerH12() - ItemsBlock(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) + tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) + + 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 } } -@Composable -private fun SearchBar(searchBarUM: SearchBarUM) { - SearchBar( - state = searchBarUM, - colors = TangemSearchBarDefaults.secondaryTextFieldColors, - ) +private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) { + item("SearchKey") { + SearchBar( + state = searchBarUM, + colors = TangemSearchBarDefaults.secondaryTextFieldColors, + modifier = modifier, + ) + } } -@Composable -private fun ItemsBlock(items: ImmutableList, isBalanceHidden: Boolean) { - items.fastForEachIndexed { index, item -> - key(item.id) { +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, @@ -92,13 +112,70 @@ private fun ItemsBlock(items: ImmutableList, isBalanceHidden: .roundedShapeItemDecoration( currentIndex = index, lastIndex = items.lastIndex, - addDefaultPadding = false, 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), + ) } } @@ -107,12 +184,12 @@ private fun ItemsBlock(items: ImmutableList, isBalanceHidden: @Composable private fun Preview_TokenList(@PreviewParameter(PreviewTokenListUMProvider::class) state: TokenListUM) { TangemThemePreview { - TokenList( - state = state, - modifier = Modifier - .fillMaxWidth() - .background(color = TangemTheme.colors.background.secondary) - .padding(16.dp), - ) + LazyColumn( + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) { + onrampTokenList( + state = state, + ) + } } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt index 684e6d0cc3..2f7e48ad75 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import kotlinx.collections.immutable.persistentListOf internal class PreviewTokenListUMProvider : PreviewParameterProvider { @@ -42,6 +43,7 @@ internal class PreviewTokenListUMProvider : PreviewParameterProvider StakeScreenSource.Info StakingStep.Amount -> StakeScreenSource.Amount - StakingStep.Confirmation -> StakeScreenSource.Confirmation + StakingStep.Success, + StakingStep.Confirmation, + -> StakeScreenSource.Confirmation StakingStep.Validators, StakingStep.RestakeValidator, StakingStep.RewardsValidators, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index e1e93296cf..92676e87dd 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -87,7 +87,7 @@ internal class StakingStateController @Inject constructor( cryptoCurrencyBlockchainId = "", currentStep = StakingStep.InitialInfo, initialInfoState = StakingStates.InitialInfoState.Empty(), - amountState = AmountState.Empty(isRedesignEnabled = false), + amountState = AmountState.Empty, validatorState = StakingStates.ValidatorState.Empty(), rewardsValidatorsState = StakingStates.RewardsValidatorsState.Empty(), confirmationState = StakingStates.ConfirmationState.Empty(), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt index 5ba32182c8..a4d0229f85 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt @@ -42,6 +42,7 @@ internal class StakingStateRouter( StakingStep.Amount, -> showConfirmation() StakingStep.Confirmation -> showInitial() + StakingStep.Success -> appRouter.pop() } } @@ -65,6 +66,7 @@ internal class StakingStateRouter( } } StakingStep.Validators -> showConfirmation() + StakingStep.Success -> appRouter.pop() } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index ae13e50f62..56f8f60341 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -138,4 +138,5 @@ enum class StakingStep { RestakeValidator, Confirmation, Validators, + Success, } \ 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 936fea5c1b..9bb2b5eac3 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 @@ -46,10 +46,11 @@ internal class SetAmountDataTransformer( return prevState.copy( amountState = AmountStateConverter( clickIntents = clickIntents, - cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, - appCurrencyProvider = appCurrencyProvider, iconStateConverter = iconStateConverter, maxEnterAmount = maxEnterAmount, + appCurrency = appCurrencyProvider(), + cryptoCurrencyStatus = cryptoCurrencyStatusProvider(), + isBalanceHidden = false, ).convert( AmountParameters( title = title, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt index a5d35a5215..e51cdf8d61 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -25,7 +25,6 @@ internal class SetButtonsStateTransformer( val buttonsState = if (prevState.isButtonsVisible()) { NavigationButtonsState.Data( primaryButton = getPrimaryButton(prevState), - prevButton = getPrevButton(prevState), extraButtons = getExtraButtons(prevState).takeIf { txUrl != null }, txUrl = txUrl, onTextClick = urlOpener::openUrl, @@ -64,18 +63,6 @@ internal class SetButtonsStateTransformer( ) } - private fun getPrevButton(prevState: StakingUiState): NavigationButton? { - return NavigationButton( - textReference = TextReference.EMPTY, - iconRes = R.drawable.ic_back_24, - isSecondary = true, - isIconVisible = true, - shouldShowProgress = false, - isEnabled = true, - onClick = prevState.clickIntents::onPrevClick, - ).takeIf { prevState.currentStep.isPrevButtonVisible() } - } - private fun getExtraButtons(prevState: StakingUiState): Pair { return NavigationButton( textReference = resourceReference(R.string.common_explore), @@ -111,7 +98,7 @@ internal class SetButtonsStateTransformer( resourceReference(R.string.common_stake) } } - + StakingStep.Success -> resourceReference(R.string.common_close) StakingStep.Confirmation -> getConfirmationButtonText() StakingStep.Validators -> resourceReference(R.string.common_continue) StakingStep.Amount, @@ -125,21 +112,17 @@ internal class SetButtonsStateTransformer( val confirmationState = confirmationState as? StakingStates.ConfirmationState.Data val amountState = amountState as? AmountState.Data return if (confirmationState != null && amountState != null) { - if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { - resourceReference(R.string.common_close) - } else { - when (actionType) { - is StakingActionCommonType.Enter -> { - val amount = amountState.amountTextField.cryptoAmount.value.orZero() - if (confirmationState.isApprovalNeeded && confirmationState.allowance < amount) { - resourceReference(R.string.give_permission_title) - } else { - resourceReference(R.string.common_stake) - } + when (actionType) { + is StakingActionCommonType.Enter -> { + val amount = amountState.amountTextField.cryptoAmount.value.orZero() + if (confirmationState.isApprovalNeeded && confirmationState.allowance < amount) { + resourceReference(R.string.give_permission_title) + } else { + resourceReference(R.string.common_stake) } - is StakingActionCommonType.Exit -> resourceReference(R.string.common_unstake) - is StakingActionCommonType.Pending -> confirmationState.pendingAction?.type.getPendingActionTitle() } + is StakingActionCommonType.Exit -> resourceReference(R.string.common_unstake) + is StakingActionCommonType.Pending -> confirmationState.pendingAction?.type.getPendingActionTitle() } } else { resourceReference(R.string.common_close) @@ -155,6 +138,7 @@ internal class SetButtonsStateTransformer( StakingStep.Amount -> clickIntents.onAmountEnterClick() StakingStep.Confirmation -> onConfirmationClick() StakingStep.RewardsValidators -> Unit + StakingStep.Success -> clickIntents.onBackClick() } } @@ -178,17 +162,6 @@ internal class SetButtonsStateTransformer( } } - private fun StakingStep.isPrevButtonVisible(): Boolean = when (this) { - StakingStep.InitialInfo, - StakingStep.RewardsValidators, - StakingStep.RestakeValidator, - StakingStep.Confirmation, - StakingStep.Validators, - -> false - StakingStep.Amount, - -> true - } - private fun StakingUiState.isPrimaryButtonDisabled(): Boolean { val initialState = initialInfoState as? StakingStates.InitialInfoState.Data val hasNotStaking = initialState?.yieldBalance == InnerYieldBalanceState.Empty @@ -205,6 +178,7 @@ internal class SetButtonsStateTransformer( StakingStep.RewardsValidators -> rewardsValidatorsState.isPrimaryButtonEnabled StakingStep.RestakeValidator, StakingStep.Validators, + StakingStep.Success, -> true } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt index 7eb0589c34..6f745765ff 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingStep import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.features.staking.impl.presentation.state.TransactionDoneState import com.tangem.utils.transformer.Transformer @@ -17,6 +18,7 @@ internal class SetConfirmationStateCompletedTransformer( override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( confirmationState = prevState.confirmationState.copyWrapped(), + currentStep = StakingStep.Success, ) } 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 d26cfd7e24..f36b37e184 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 @@ -229,10 +229,11 @@ internal class SetInitialDataStateTransformer( ) return AmountStateConverter( clickIntents = clickIntents, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - appCurrencyProvider = appCurrencyProvider, + cryptoCurrencyStatus = cryptoCurrencyStatus, + appCurrency = appCurrencyProvider(), iconStateConverter = iconStateConverter, maxEnterAmount = maxEnterAmount, + isBalanceHidden = false, ).convert( AmountParameters( title = stringReference(userWalletProvider().name), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt index d16bb669e4..12b26f1cfc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.isNullOrEmpty import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -28,7 +29,7 @@ internal object SetTitleTransformer : Transformer { R.string.staking_title_stake, wrappedList(prevState.cryptoCurrencyName), ) - + StakingStep.Success -> TextReference.EMPTY StakingStep.Confirmation -> { when (actionType) { is StakingActionCommonType.Enter -> resourceReference( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt index 42f7777f9f..7f3853dbbd 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt @@ -14,12 +14,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData -import com.tangem.common.ui.amountScreen.ui.AmountBlock +import com.tangem.common.ui.amountScreen.ui.AmountBlockV2 import com.tangem.core.ui.components.transactions.TransactionDoneTitle 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.staking.impl.R +import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState import com.tangem.features.staking.impl.presentation.state.StakingNotification import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -30,7 +31,6 @@ import com.tangem.features.staking.impl.presentation.state.stub.StakingClickInte import com.tangem.features.staking.impl.presentation.ui.block.NotificationsBlock import com.tangem.features.staking.impl.presentation.ui.block.StakingFeeBlock import com.tangem.features.staking.impl.presentation.ui.block.ValidatorBlock -import com.tangem.features.staking.impl.presentation.model.StakingClickIntents @Suppress("LongParameterList") @Composable @@ -60,7 +60,7 @@ internal fun StakingConfirmationContent( subtitle = resourceReference(R.string.staking_transaction_in_progress_text), ) } - AmountBlock( + AmountBlockV2( amountState = amountState, isClickDisabled = !state.isAmountEditable || isTransactionSent || isTransactionInProgress, isEditingDisabled = !state.isAmountEditable && state.innerState != InnerConfirmationStakingState.COMPLETED, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt index c8f15d868e..0196216ea0 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -94,6 +94,7 @@ private fun StakingAppBar(uiState: StakingUiState) { val (backIcon, click) = when (uiState.currentStep) { StakingStep.Amount, StakingStep.Confirmation, + StakingStep.Success, -> R.drawable.ic_close_24 to uiState.clickIntents::onBackClick StakingStep.Validators, StakingStep.RewardsValidators, @@ -111,6 +112,7 @@ private fun StakingAppBar(uiState: StakingUiState) { ) } +@Suppress("LongMethod") @Composable private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = Modifier) { val currentScreen = uiState.currentStep @@ -139,10 +141,10 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M contentAlignment = Alignment.TopCenter, label = "Staking Screen Navigation", transitionSpec = { - val direction = if (initialState.ordinal < targetState.ordinal) { - AnimatedContentTransitionScope.SlideDirection.Start - } else { - AnimatedContentTransitionScope.SlideDirection.End + val direction = when { + targetState == StakingStep.Success -> AnimatedContentTransitionScope.SlideDirection.Up + initialState.ordinal < targetState.ordinal -> AnimatedContentTransitionScope.SlideDirection.Start + else -> AnimatedContentTransitionScope.SlideDirection.End } slideIntoContainer(towards = direction, animationSpec = tween()) @@ -166,7 +168,6 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M } StakingStep.Amount -> AmountScreenContent( amountState = uiState.amountState, - isBalanceHidden = uiState.isBalanceHidden, clickIntents = uiState.clickIntents, modifier = Modifier.background(TangemTheme.colors.background.secondary), ) @@ -176,6 +177,12 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M validatorState = uiState.validatorState, clickIntents = uiState.clickIntents, ) + StakingStep.Success -> StakingSuccessContent( + amountState = uiState.amountState, + state = uiState.confirmationState, + validatorState = uiState.validatorState, + clickIntents = uiState.clickIntents, + ) StakingStep.RestakeValidator, StakingStep.Validators, -> StakingValidatorListContent( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt new file mode 100644 index 0000000000..d9297f768d --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt @@ -0,0 +1,86 @@ +package com.tangem.features.staking.impl.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData +import com.tangem.common.ui.amountScreen.ui.AmountBlock +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +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.staking.impl.R +import com.tangem.features.staking.impl.presentation.model.StakingClickIntents +import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState +import com.tangem.features.staking.impl.presentation.state.StakingNotification +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData +import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData +import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub +import com.tangem.features.staking.impl.presentation.ui.block.NotificationsBlock +import com.tangem.features.staking.impl.presentation.ui.block.StakingFeeBlock +import com.tangem.features.staking.impl.presentation.ui.block.ValidatorBlock + +@Suppress("LongParameterList") +@Composable +internal fun StakingSuccessContent( + amountState: AmountState, + state: StakingStates.ConfirmationState, + validatorState: StakingStates.ValidatorState, + clickIntents: StakingClickIntents, +) { + if (state !is StakingStates.ConfirmationState.Data) return + val isTransactionSent = state.innerState == InnerConfirmationStakingState.COMPLETED + val isTransactionInProgress = state.notifications.any { it is StakingNotification.Warning.TransactionInProgress } + Column( + modifier = Modifier + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + TransactionDoneTitle( + title = resourceReference(R.string.common_in_progress), + subtitle = resourceReference(R.string.staking_transaction_in_progress_text), + ) + AmountBlock( + amountState = amountState, + isClickDisabled = !state.isAmountEditable || isTransactionSent || isTransactionInProgress, + isEditingDisabled = !state.isAmountEditable && state.innerState != InnerConfirmationStakingState.COMPLETED, + onClick = clickIntents::onPrevClick, + ) + ValidatorBlock( + validatorState = validatorState, + isClickable = !isTransactionInProgress, + onClick = clickIntents::openValidators, + ) + StakingFeeBlock(feeState = state.feeState) + NotificationsBlock(notifications = state.notifications) + Spacer(Modifier) + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_StakingConfirmationContent() { + TangemThemePreview { + Column(Modifier.background(TangemTheme.colors.background.primary)) { + StakingConfirmationContent( + amountState = AmountStatePreviewData.amountState, + state = ConfirmationStatePreviewData.assentStakingState, + validatorState = ValidatorStatePreviewData.validatorState, + clickIntents = StakingClickIntentsStub, + ) + } + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt index b2d95fed03..62eb8a0d3b 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt @@ -64,19 +64,13 @@ sealed class SwapAmountFieldUM { data class Empty( override val amountType: SwapAmountType, ) : SwapAmountFieldUM() { - override val amountField: AmountState = AmountState.Empty( - isPrimaryButtonEnabled = false, - isRedesignEnabled = true, - ) + override val amountField: AmountState = AmountState.Empty } data class Loading( override val amountType: SwapAmountType, ) : SwapAmountFieldUM() { - override val amountField: AmountState = AmountState.Empty( - isPrimaryButtonEnabled = false, - isRedesignEnabled = true, - ) + override val amountField: AmountState = AmountState.Empty } data class Content( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt index c8eb091285..a490422d2f 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt @@ -1,7 +1,7 @@ package com.tangem.features.swap.v2.impl.amount.model.converter import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.common.ui.amountScreen.converters.AmountStateConverterV2 +import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.core.ui.components.atoms.text.TextEllipsis @@ -44,7 +44,7 @@ internal class SwapAmountFieldConverter( subtitleEllipsisRight = TextEllipsis.OffsetEnd(appCurrency.symbol.length), priceImpact = null, isClickEnabled = selectedType.isViewingField(), - amountField = AmountStateConverterV2( + amountField = AmountStateConverter( clickIntents = clickIntents, appCurrency = appCurrency, cryptoCurrencyStatus = cryptoCurrencyStatus, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt index 52fc0217cd..b6b0ccec59 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt @@ -46,9 +46,7 @@ internal class SwapAmountBalanceHiddenTransformer( val newData = recalculatedPrimary.amountField newData.copy( amountTextField = oldData.amountTextField, - selectedButton = oldData.selectedButton, isPrimaryButtonEnabled = oldData.isPrimaryButtonEnabled, - isSegmentedButtonsEnabled = oldData.isSegmentedButtonsEnabled, isEditingDisabled = oldData.isEditingDisabled, reduceAmountBy = oldData.reduceAmountBy, isIgnoreReduce = oldData.isIgnoreReduce, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt index bc921fd483..54177a3435 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt @@ -127,7 +127,6 @@ private fun ConstraintLayoutScope.SwapAmountBlock( AmountBlockV2( amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy( title = resourceReference(R.string.send_with_swap_recipient_amount_title), - availableBalance = TextReference.EMPTY, availableBalanceCrypto = TextReference.EMPTY, ) ?: amountUM.secondaryAmount.amountField, isClickDisabled = true, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt index 3e6da5d36b..352cd998fa 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt @@ -97,9 +97,7 @@ internal data object SwapAmountContentPreview { val defaultState = SwapAmountUM.Content( primaryAmount = SwapAmountFieldUM.Content( amountType = SwapAmountType.From, - amountField = AmountStatePreviewData.amountState.copy( - availableBalance = stringReference("Balance: 100 BTC"), - ), + amountField = AmountStatePreviewData.amountState, title = stringReference("Tether"), subtitleLeft = stringReference("11 101,123123456 BTC"), subtitleRight = stringReference(" ${StringsSigns.DOT} 1 212,12 $"), @@ -112,7 +110,6 @@ internal data object SwapAmountContentPreview { amountType = SwapAmountType.To, amountField = AmountStatePreviewData.amountState.copy( title = stringReference("Amount to receive"), - availableBalance = TextReference.EMPTY, ), title = stringReference("Shiba Inu"), priceImpact = stringReference("(-10%)"), 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 8b8331fff7..6443a3dd5f 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 @@ -390,7 +390,7 @@ fun Token( textAlign = TextAlign.Center, modifier = Modifier .defaultMinSize(minWidth = TangemTheme.dimens.size80) - .testTag(SwapTokenScreenTestTags.TOKEN_NAME), + .testTag(SwapTokenScreenTestTags.TOKEN_SYMBOL), ) } } 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 b430960c14..49dccbf15e 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 @@ -21,6 +21,7 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor companion object { val loadingUM = TangemPayTxHistoryUM.Loading(isBalanceHidden = true) val emptyUM = TangemPayTxHistoryUM.Empty(isBalanceHidden = true) + val errorUM = TangemPayTxHistoryUM.Error(isBalanceHidden = true, onReload = {}) val contentUM = TangemPayTxHistoryUM.Content( isBalanceHidden = false, loadMore = { false }, @@ -34,11 +35,35 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor amount = "-4.99 USD", amountColor = { TangemTheme.colors.text.primary1 }, time = "16:41", - title = stringReference("Starbucks"), + title = stringReference("StarbucksStarbucksStarbucksStarbucks"), subtitle = stringReference("Food&Drinks"), iconUrl = null, ), ), + TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( + transaction = TangemPayTransactionState.Content.Payment( + id = "signiferumque", + amount = "-126.20 USD", + amountColor = { TangemTheme.colors.text.primary1 }, + time = "12:04", + onClick = {}, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + isIncome = false, + ), + ), + TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( + transaction = TangemPayTransactionState.Content.Payment( + id = "signiferumque", + amount = "+126.20 USD", + amountColor = { TangemTheme.colors.text.accent }, + time = "12:04", + onClick = {}, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + isIncome = true, + ), + ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEmptyTransactionHistoryState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEmptyTransactionHistoryState.kt new file mode 100644 index 0000000000..50816ac2a7 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEmptyTransactionHistoryState.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R + +internal sealed class TangemPayEmptyTransactionHistoryState { + + abstract val iconRes: Int + abstract val text: TextReference + + data class FailedToLoad( + private val onReload: () -> Unit, + ) : TangemPayEmptyTransactionHistoryState() { + override val iconRes: Int = R.drawable.ic_alert_history_64 + override val text: TextReference = resourceReference(R.string.transaction_history_error_failed_to_load) + val actionButtonConfig = ActionButtonConfig( + text = resourceReference(R.string.common_reload), + iconResId = R.drawable.ic_refresh_24, + onClick = onReload, + ) + } + + data object Empty : TangemPayEmptyTransactionHistoryState() { + override val iconRes: Int = R.drawable.ic_empty_token_64 + override val text: TextReference = resourceReference(R.string.transaction_history_empty_transactions) + } +} \ 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 1b3778feb0..ac67e1d5b4 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 @@ -54,11 +54,16 @@ internal class TangemPayTxHistoryModel @Inject constructor( .onEach(::updateState) .launchIn(modelScope) listManager.paginationStatus - .onEach { paginationStatus -> handlePaginationStatus(paginationStatus) } + .onEach(::handlePaginationStatus) + .launchIn(modelScope) + listManager.emptyStatus + .onEach(::handleEmptyState) .launchIn(modelScope) } private fun updateState(items: ImmutableList) { + if (items.isEmpty()) return // fast exit. If items is empty, no need to update ui items + uiState.update { state -> if (state is TangemPayTxHistoryUM.Content) { state.copy(items = items) @@ -72,6 +77,12 @@ internal class TangemPayTxHistoryModel @Inject constructor( } } + private fun handleEmptyState(isEmpty: Boolean) { + if (isEmpty) { + uiState.update { getEmptyState(it.isBalanceHidden) } + } + } + private fun handlePaginationStatus(status: PaginationStatus<*>) { uiState.update { state -> when (status) { @@ -108,6 +119,10 @@ internal class TangemPayTxHistoryModel @Inject constructor( Timber.d("onTransactionClick: $item") } + private fun getEmptyState(isBalanceHidden: Boolean): TangemPayTxHistoryUM.Empty { + return TangemPayTxHistoryUM.Empty(isBalanceHidden = isBalanceHidden) + } + private fun getErrorState(isBalanceHidden: Boolean): TangemPayTxHistoryUM.Error { return TangemPayTxHistoryUM.Error(isBalanceHidden = isBalanceHidden, onReload = ::reload) } 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 9184fac026..efd45df7b3 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 @@ -8,6 +8,7 @@ import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.domain.visa.model.TangemPayTxHistoryItem 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 org.joda.time.DateTimeZone @@ -25,14 +26,23 @@ internal class TangemPayTxHistoryItemsConverter( private fun convertSpend(spend: TangemPayTxHistoryItem.Spend): TangemPayTransactionState.Content.Spend { val localDate = spend.date.withZone(DateTimeZone.getDefault()) - val amount = spend.amount.format { + val amountPrefix = when (spend.status) { + TangemPayTxHistoryItem.Status.DECLINED -> "" + else -> StringsSigns.MINUS + } + val amount = amountPrefix + spend.amount.format { fiat(fiatCurrencyCode = spend.currency.currencyCode, fiatCurrencySymbol = spend.currency.symbol) } return TangemPayTransactionState.Content.Spend( id = spend.id, onClick = { txHistoryUiActions.onTransactionClick(spend) }, amount = amount, - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = { + 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), time = DateTimeFormatters.formatDate(localDate, DateTimeFormatters.timeFormatter), @@ -41,7 +51,8 @@ internal class TangemPayTxHistoryItemsConverter( } private fun convertPayment(payment: TangemPayTxHistoryItem.Payment): TangemPayTransactionState.Content.Payment { - val amount = payment.amount.format { + val amountPrefix = if (payment.amount.isPositive()) StringsSigns.PLUS else StringsSigns.MINUS + val amount = amountPrefix + payment.amount.format { fiat(fiatCurrencyCode = payment.currency.currencyCode, fiatCurrencySymbol = payment.currency.symbol) } val title = if (payment.amount.isPositive()) "Deposit" else "Withdrawal" @@ -64,7 +75,7 @@ internal class TangemPayTxHistoryItemsConverter( } private fun convertFee(fee: TangemPayTxHistoryItem.Fee): TangemPayTransactionState.Content.Fee { - val amount = fee.amount.format { + val amount = StringsSigns.MINUS + fee.amount.format { fiat(fiatCurrencyCode = fee.currency.currencyCode, fiatCurrencySymbol = fee.currency.symbol) } return TangemPayTransactionState.Content.Fee( 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 c2e6cc25f7..bd9cc78a83 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,12 +2,7 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -15,12 +10,8 @@ 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.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,21 +35,14 @@ 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.TextReference -import com.tangem.core.ui.extensions.orMaskWithStars -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.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig -import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.* import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @@ -415,8 +399,8 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi ) } -@Preview(device = Devices.PIXEL_7_PRO, group = "day") -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO, group = "night") +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) @Composable private fun TangemPayDetailsScreenPreview( @PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM, @@ -473,4 +457,26 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + PreviewTangemPayTxHistoryComponent.loadingUM, + PreviewTangemPayTxHistoryComponent.contentUM, + PreviewTangemPayTxHistoryComponent.emptyUM, + PreviewTangemPayTxHistoryComponent.errorUM, + ), ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt index b7c07cbc03..bcaec6f86e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ChainStyle import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension @@ -27,6 +28,7 @@ import coil.compose.rememberAsyncImagePainter import com.tangem.core.ui.R import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.buttons.actions.ActionButton import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle import com.tangem.core.ui.decorations.roundedShapeItemDecoration @@ -34,7 +36,9 @@ import com.tangem.core.ui.extensions.orMaskWithStars 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.test.EmptyTransactionBlockTestTags import com.tangem.core.ui.test.TransactionHistoryBlockTestTags +import com.tangem.features.tangempay.entity.TangemPayEmptyTransactionHistoryState import com.tangem.features.tangempay.entity.TangemPayTransactionState import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM @@ -43,12 +47,25 @@ private const val LOAD_ITEMS_BUFFER = 20 internal fun LazyListScope.tangemPayTxHistoryItems(listState: LazyListState, state: TangemPayTxHistoryUM) { when (state) { is TangemPayTxHistoryUM.Content -> contentItems(listState = listState, state = state) - is TangemPayTxHistoryUM.Empty -> TODO("[REDACTED_JIRA]") - is TangemPayTxHistoryUM.Error -> TODO("[REDACTED_JIRA]") + is TangemPayTxHistoryUM.Empty -> nonContentItem(state = TangemPayEmptyTransactionHistoryState.Empty) + is TangemPayTxHistoryUM.Error -> nonContentItem( + state = TangemPayEmptyTransactionHistoryState.FailedToLoad(onReload = state.onReload), + ) is TangemPayTxHistoryUM.Loading -> loadingItems(state = state) } } +private fun LazyListScope.nonContentItem(state: TangemPayEmptyTransactionHistoryState, modifier: Modifier = Modifier) { + item(key = state::class.java, contentType = state::class.java) { + TangemPayEmptyTransactionBlock( + state = state, + modifier = modifier + .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) + } +} + private fun LazyListScope.contentItems(listState: LazyListState, state: TangemPayTxHistoryUM.Content) { itemsIndexed( items = state.items, @@ -224,7 +241,7 @@ private fun TangemPayTransaction( bottom.linkTo(timestampItem.top) start.linkTo(titleItem.end) end.linkTo(parent.end) - width = Dimension.fillToConstraints + width = Dimension.preferredWrapContent }, ) @@ -346,7 +363,7 @@ private fun Amount(state: TangemPayTransactionState, isBalanceHidden: Boolean, m } is TangemPayTransactionState.Loading -> { RectangleShimmer( - modifier = modifier.size(width = TangemTheme.dimens.size40, height = TangemTheme.dimens.size12), + modifier = modifier.size(width = TangemTheme.dimens.size72, height = TangemTheme.dimens.size12), ) } } @@ -370,4 +387,49 @@ private fun Timestamp(state: TangemPayTransactionState, modifier: Modifier = Mod ) } } +} + +@Composable +private fun TangemPayEmptyTransactionBlock( + state: TangemPayEmptyTransactionHistoryState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(color = TangemTheme.colors.background.primary) + .padding(vertical = TangemTheme.dimens.spacing24) + .testTag(EmptyTransactionBlockTestTags.BLOCK), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .testTag(EmptyTransactionBlockTestTags.ICON), + painter = painterResource(id = state.iconRes), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + ) + + Text( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing32) + .testTag(EmptyTransactionBlockTestTags.TEXT), + textAlign = TextAlign.Center, + text = state.text.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + when (state) { + is TangemPayEmptyTransactionHistoryState.Empty -> Unit + is TangemPayEmptyTransactionHistoryState.FailedToLoad -> ActionButton( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + config = state.actionButtonConfig, + ) + } + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt index 3ccf9cf202..40cd31c7da 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt @@ -33,6 +33,7 @@ internal class TangemPayTxHistoryListManager( private val uiManager = TangemPayTxHistoryUiManager(state = state, txHistoryUiActions = txHistoryUiActions) val uiItems: Flow> = uiManager.items + val emptyStatus: Flow = state.map { it.isEmpty }.distinctUntilChanged() val paginationStatus: Flow> = state.map { it.status }.distinctUntilChanged() suspend fun launchPagination() = coroutineScope { @@ -80,6 +81,7 @@ internal class TangemPayTxHistoryListManager( newCurrencyBatches = batchListState.data, clearUiBatches = clearUiBatches, ), + isEmpty = batchListState.status is PaginationStatus.EndOfPagination && batchListState.data.isEmpty(), ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt index 5717238dda..5df7a11936 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt @@ -7,4 +7,5 @@ import com.tangem.pagination.PaginationStatus internal data class TangemPayTxHistoryState( val status: PaginationStatus<*> = PaginationStatus.None, val uiBatches: List>> = listOf(), + val isEmpty: Boolean = false, ) \ No newline at end of file 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 65caca46f0..4b41785218 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 @@ -56,7 +56,7 @@ internal class TangemPayOnboardingModel @Inject constructor( repository.getCustomerInfo() .onRight { customerInfo -> when { - !customerInfo.isKycApproved() -> { + !customerInfo.isKycApproved -> { when (params) { is TangemPayOnboardingComponent.Params.Deeplink -> screenState.value = screenState.value.copy(fullScreenLoading = false) 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 7240b154e5..8f2605df2e 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 @@ -4,6 +4,7 @@ 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.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -11,6 +12,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.WindowInsetsZero @Composable internal fun TandemPayOnboardingScreen( @@ -20,7 +22,7 @@ internal fun TandemPayOnboardingScreen( modifier: Modifier = Modifier, ) { Scaffold( - modifier = modifier, + modifier = modifier.systemBarsPadding(), topBar = { AppBarWithBackButton( modifier = Modifier.statusBarsPadding(), @@ -28,6 +30,7 @@ internal fun TandemPayOnboardingScreen( iconRes = R.drawable.ic_back_24, ) }, + contentWindowInsets = WindowInsetsZero, content = { paddingValues -> TangemPayOnboardingContent( modifier = Modifier diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 1428366edc..bab7b098d3 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -17,7 +17,7 @@ import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeActivity import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen -import com.tangem.feature.tester.presentation.accounts.viewmodel.AccountsViewModel +import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel import com.tangem.feature.tester.presentation.actions.TesterActionsScreen import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen @@ -155,7 +155,7 @@ internal class TesterActivity : ComposeActivity() { } composable(route = TesterScreen.ACCOUNTS.name) { - val viewModel = hiltViewModel().apply { + val viewModel = hiltViewModel().apply { setupNavigation(innerTesterRouter) } val state by viewModel.uiState.collectAsStateWithLifecycle() 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 168afd1186..64cf095f9c 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,9 +9,8 @@ internal data class AccountsUM( val onBackClick: () -> Unit, val walletSelector: WalletSelector, val accountListBottomSheetConfig: AccountListBottomSheetConfig, - val onAccountsClick: () -> Unit, + val onAccountsClick: () -> Boolean, val onFetchAccountsClick: () -> Unit, - val onCreateMainAccountClick: () -> Unit, val onClearETagClick: () -> Unit, ) { diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt index 66202d3842..8ee0fd82be 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt @@ -62,8 +62,9 @@ internal fun AccountsScreen(state: AccountsUM, modifier: Modifier = Modifier) { ManageAccountsButtons( state = state, onAccountsClick = { context -> - if (state.accountListBottomSheetConfig.accounts.isNotEmpty()) { - state.onAccountsClick() + val isEmpty = state.onAccountsClick() + + if (!isEmpty) { isAccountListShown = true } else { Toast.makeText(context, "No accounts found", Toast.LENGTH_SHORT).show() @@ -242,16 +243,4 @@ private fun LazyListScope.ManageAccountsButtons(state: AccountsUM, onAccountsCli .fillMaxWidth(), ) } - - if (state.accountListBottomSheetConfig.accounts.none { it.isMainAccount }) { - item { - PrimaryButton( - text = "Create Main account", - onClick = state.onCreateMainAccountClick, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .fillMaxWidth(), - ) - } - } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/TesterAccountsViewModel.kt similarity index 80% rename from features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt rename to features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/TesterAccountsViewModel.kt index 31be606a60..0824ae8aaa 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/TesterAccountsViewModel.kt @@ -6,13 +6,9 @@ import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.domain.account.fetcher.SingleAccountListFetcher 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.supplier.SingleAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository -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.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.tester.presentation.accounts.entity.AccountsUM @@ -28,11 +24,10 @@ import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel -internal class AccountsViewModel @Inject constructor( +internal class TesterAccountsViewModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val singleAccountListFetcher: SingleAccountListFetcher, private val singleAccountListSupplier: SingleAccountListSupplier, - private val accountsCRUDRepository: AccountsCRUDRepository, private val eTagsStore: ETagsStore, private val dispatchers: CoroutineDispatcherProvider, ) : ViewModel() { @@ -88,7 +83,6 @@ internal class AccountsViewModel @Inject constructor( ), onAccountsClick = ::updateAccountsList, onFetchAccountsClick = ::fetchAccounts, - onCreateMainAccountClick = ::createMainAccount, onClearETagClick = ::clearETag, ) } @@ -107,8 +101,8 @@ internal class AccountsViewModel @Inject constructor( } } - private fun updateAccountsList() { - val userWalletId = uiState.value.walletSelector.selected?.walletId ?: return + private fun updateAccountsList(): Boolean { + val userWalletId = uiState.value.walletSelector.selected?.walletId ?: return false val accounts = walletAccounts.value[userWalletId]?.accounts ?.filterIsInstance() @@ -122,6 +116,8 @@ internal class AccountsViewModel @Inject constructor( ), ) } + + return accounts.isEmpty() } private fun fetchAccounts() { @@ -134,28 +130,6 @@ internal class AccountsViewModel @Inject constructor( } } - private fun createMainAccount() { - viewModelScope.launch { - val userWallet = uiState.value.walletSelector.selected ?: return@launch - - // It's temporary solution to create main account for testing purposes - val accountList = AccountList( - userWallet = userWallet, - accounts = setOf( - Account.CryptoPortfolio.createMainAccount(userWallet.walletId).copy( - accountName = AccountName.invoke(value = "Main Account").getOrNull()!!, - ), - ), - totalAccounts = 1, - sortType = TokensSortType.NONE, - groupType = TokensGroupType.NONE, - ) - .getOrNull()!! - - accountsCRUDRepository.saveAccounts(accountList) - } - } - private fun clearETag() { viewModelScope.launch { val userWallet = uiState.value.walletSelector.selected ?: return@launch diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt index 959045d098..39b2363e21 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -32,6 +33,7 @@ import com.tangem.core.ui.components.* 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.TokenReceiveQrCodeBottomSheetTestTags import com.tangem.features.tokenreceive.impl.R import com.tangem.features.tokenreceive.ui.state.QrCodeUM import kotlinx.coroutines.launch @@ -88,6 +90,7 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, style = TangemTheme.typography.h3, + modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.TITLE), ) SpacerH(20.dp) @@ -99,7 +102,8 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net color = TangemTheme.colors.icon.constant, shape = RoundedCornerShape(8.dp), ) - .padding(8.dp), + .padding(8.dp) + .testTag(TokenReceiveQrCodeBottomSheetTestTags.QR_CODE), ) { Image( @@ -126,6 +130,7 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, style = TangemTheme.typography.subtitle1, + modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.ADDRESS), ) } } diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt index ed2a67a9be..6f4c57d692 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -25,6 +26,7 @@ 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.TokenReceiveWarningBottomSheetTestTags import com.tangem.features.tokenreceive.impl.R import com.tangem.features.tokenreceive.ui.state.WarningUM @@ -40,7 +42,7 @@ internal fun TokenReceiveWarningContent(warningUM: WarningUM) { start = 16.dp, end = 16.dp, bottom = 16.dp, - ), + ).testTag(TokenReceiveWarningBottomSheetTestTags.BOTTOM_SHEET), horizontalAlignment = Alignment.CenterHorizontally, ) { CurrencyIcon( diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index d359b824c4..53d1a92331 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -39,7 +39,6 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { name = accountName, icon = AccountIconPreviewData.randomAccountIcon(), ), - label = null, ) private val previewState = WalletSettingsUM( diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index e7864d0edc..5453245f07 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -22,6 +22,7 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage import com.tangem.core.ui.message.bottomSheetMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.scan.CardDTO @@ -81,6 +82,7 @@ internal class WalletSettingsModel @Inject constructor( private val isUpgradeWalletNotificationEnabledUseCase: IsUpgradeWalletNotificationEnabledUseCase, private val dismissUpgradeWalletNotificationUseCase: DismissUpgradeWalletNotificationUseCase, private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, ) : Model() { val params: WalletSettingsComponent.Params = paramsContainer.require() @@ -173,6 +175,7 @@ internal class WalletSettingsModel @Inject constructor( isUpgradeNotificationEnabled: Boolean, accountList: List, ): PersistentList { + val accountsFeatureEnabled = accountsFeatureToggles.isFeatureEnabled val isMultiCurrency = when (userWallet) { is UserWallet.Cold -> userWallet.isMultiCurrency is UserWallet.Hot -> true @@ -188,13 +191,18 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus == CardDTO.BackupStatus.NoBackup is UserWallet.Hot -> false }, - isManageTokensAvailable = isMultiCurrency, + isManageTokensAvailable = !accountsFeatureEnabled && isMultiCurrency, isNFTFeatureEnabled = isMultiCurrency, isNFTEnabled = isNFTEnabled, onCheckedNFTChange = ::onCheckedNFTChange, forgetWallet = { val message = DialogMessage( - message = resourceReference(R.string.user_wallet_list_delete_prompt), + message = resourceReference( + id = when (userWallet) { + is UserWallet.Cold -> R.string.user_wallet_list_delete_prompt + is UserWallet.Hot -> R.string.user_wallet_list_delete_hw_prompt + }, + ), firstActionBuilder = { EventMessageAction( title = resourceReference(R.string.common_delete), diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 6d2bf36754..8375ca44c6 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM @@ -190,12 +191,14 @@ internal class ItemsBuilder @Inject constructor( text = resourceReference(R.string.common_backup), iconRes = R.drawable.ic_more_cards_24, onClick = { router.push(AppRoute.WalletBackup(userWalletId)) }, - label = if (hasBackup) { - null + endContent = if (hasBackup) { + BlockUM.EndContent.None } else { - LabelUM( - text = resourceReference(R.string.hw_backup_no_backup), - style = LabelStyle.WARNING, + BlockUM.EndContent.Label( + label = LabelUM( + text = resourceReference(R.string.hw_backup_no_backup), + style = LabelStyle.WARNING, + ), ) }, ).let(::add) @@ -207,7 +210,7 @@ internal class ItemsBuilder @Inject constructor( iconRes = R.drawable.ic_tether_24, onClick = { analyticsEventHandler.send(Settings.ButtonManageTokens) - router.push(AppRoute.ManageTokens(Source.SETTINGS, userWalletId)) + router.push(AppRoute.ManageTokens(Source.SETTINGS, PortfolioId(userWalletId))) }, ).let(::add) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index c843cb1153..c2d9b76a0c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -359,10 +359,10 @@ internal class WalletModel @Inject constructor( val info = tangemPayMainScreenCustomerInfoUseCase() if (info != null) { stateHolder.update( - transformer = TangemPayStateTransformer( + transformer = TangemPayInitialStateTransformer( value = info, - onIssueOrderClick = ::issueOrder, - onContinueKycClick = innerWalletRouter::openTangemPayOnboarding, + onClickIssue = ::issueOrder, + onClickKyc = innerWalletRouter::openTangemPayOnboarding, openDetails = innerWalletRouter::openTangemPayDetails, ), ) @@ -371,9 +371,9 @@ internal class WalletModel @Inject constructor( private fun issueOrder() { modelScope.launch { - stateHolder.update(TangemPayStateTransformer(issueProgressState = true)) + stateHolder.update(TangemPayIssueProgressStateTransformer()) tangemPayIssueOrderUseCase().onLeft { - stateHolder.update(TangemPayStateTransformer(issueState = true, onIssueOrderClick = ::issueOrder)) + stateHolder.update(TangemPayIssueAvailableStateTransformer(onClickIssue = ::issueOrder)) } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index eb07bc02f7..39e5b37251 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -4,17 +4,15 @@ import arrow.core.getOrElse import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.models.PortfolioId 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.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.nft.analytics.NFTAnalyticsEvent -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.TokensAction import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -39,15 +37,13 @@ internal interface WalletContentClickIntents { fun onDetailsClick() - fun onManageTokensClick() - fun onOrganizeTokensClick() fun onDismissMarketsOnboarding() - fun onTokenItemClick(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) + fun onTokenItemClick(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) - fun onTokenItemLongClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onTokenItemLongClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) fun onAccountExpandClick(account: Account) @@ -81,7 +77,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, private val dispatchers: CoroutineDispatcherProvider, - private val reduxStateHolder: ReduxStateHolder, private val walletEventSender: WalletEventSender, private val analyticsEventHandler: AnalyticsEventHandler, private val hotWalletFeatureToggles: HotWalletFeatureToggles, @@ -122,11 +117,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } } - override fun onManageTokensClick() { - reduxStateHolder.dispatch(action = TokensAction.SetArgs.ManageAccess) - router.openManageTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) - } - override fun onOrganizeTokensClick() { router.openOrganizeTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) } @@ -138,13 +128,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } } - override fun onTokenItemClick(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) { - router.openTokenDetails(portfolioId, currencyStatus) + override fun onTokenItemClick(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) { + router.openTokenDetails(userWalletId, currencyStatus) } - override fun onTokenItemLongClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onTokenItemLongClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { modelScope.launch(dispatchers.main) { - val userWalletId = portfolioId.userWalletId val userWallet = getUserWalletUseCase(userWalletId).getOrElse { Timber.e( """ @@ -160,7 +149,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( getCryptoCurrencyActionsUseCase(userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus) .take(count = 1) .collectLatest { - showActionsBottomSheet(it, userWallet, portfolioId) + showActionsBottomSheet(it, userWallet) } } } @@ -175,17 +164,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( accountDependencies.expandedAccountsHolder.collapseAccount(userWalletId, account.accountId) } - private fun showActionsBottomSheet( - tokenActionsState: TokenActionsState, - userWallet: UserWallet, - portfolioId: PortfolioId, - ) { + private fun showActionsBottomSheet(tokenActionsState: TokenActionsState, userWallet: UserWallet) { stateHolder.showBottomSheet( ActionsBottomSheetConfig( actions = MultiWalletCurrencyActionsConverter( userWallet = userWallet, clickIntents = currencyActionsClickIntents, - portfolioId = portfolioId, ).convert(tokenActionsState), ), userWallet.walletId, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index f57694c824..c2c173c0e9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -29,7 +29,6 @@ import com.tangem.domain.core.utils.lceError import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -43,12 +42,7 @@ import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase -import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase -import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase -import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase -import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.domain.tokens.* import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent @@ -57,6 +51,7 @@ import com.tangem.domain.tokens.model.analytics.TokenReceiveNewAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.AVAILABLE import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.toReasonAnalyticsText +import com.tangem.domain.tokens.model.details.TokenAction import com.tangem.domain.transaction.usecase.GetEnsNameUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase @@ -70,7 +65,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender -import com.tangem.feature.wallet.presentation.wallet.utils.WalletFeatureUseCasesFacade import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -84,7 +78,7 @@ import javax.inject.Inject interface WalletCurrencyActionsClickIntents { fun onSendClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) @@ -92,32 +86,32 @@ interface WalletCurrencyActionsClickIntents { fun onSellClick(cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason) fun onBuyClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) fun onSwapClick( cryptoCurrencyStatus: CryptoCurrencyStatus, - portfolioId: PortfolioId, + userWalletId: UserWalletId, unavailabilityReason: ScenarioUnavailabilityReason, ) fun onReceiveClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent? = null, ) - fun onStakeClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) + fun onStakeClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) fun onCopyAddressLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus): TextReference? - fun onCopyAddressClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onCopyAddressClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onHideTokensClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onHideTokensClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onPerformHideToken(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onPerformHideToken(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) fun onExploreClick() @@ -139,7 +133,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val walletManagersFacade: WalletManagersFacade, private val isDemoCardUseCase: IsDemoCardUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, - private val useCasesFacade: WalletFeatureUseCasesFacade, private val getExploreUrlUseCase: GetExploreUrlUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, @@ -158,10 +151,12 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase, private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase, + private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase, + private val removeCurrencyUseCase: RemoveCurrencyUseCase, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { override fun onSendClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) { @@ -185,27 +180,21 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch { saveViewedYieldSupplyWarningUseCase(cryptoCurrencyStatus.currency.name) stateHolder.hideBottomSheet() - navigateToSend(cryptoCurrencyStatus, portfolioId) + navigateToSend(cryptoCurrencyStatus, userWalletId) } }, ) } else { - navigateToSend(cryptoCurrencyStatus, portfolioId) + navigateToSend(cryptoCurrencyStatus, userWalletId) } } } override fun onReceiveClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent?, ) { - val userWalletId = portfolioId.userWalletId - if (portfolioId is PortfolioId.Account) { - // todo account find address - TODO("account") - } - analyticsEventHandler.send( event = TokenScreenAnalyticsEvent.ButtonWithParams.ButtonReceive( token = cryptoCurrencyStatus.currency.symbol, @@ -278,12 +267,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) } - override fun onCopyAddressClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { - val userWalletId = portfolioId.userWalletId - if (portfolioId is PortfolioId.Account) { - // todo account find address - TODO("account") - } + override fun onCopyAddressClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { analyticsEventHandler.send( event = TokenReceiveNewAnalyticsEvent.ButtonCopyAddress( token = cryptoCurrencyStatus.currency.symbol, @@ -305,7 +289,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } - override fun onHideTokensClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onHideTokensClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { analyticsEventHandler.send( event = TokenScreenAnalyticsEvent.ButtonRemoveToken(cryptoCurrencyStatus.currency.symbol), ) @@ -313,19 +297,19 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch(dispatchers.main) { walletEventSender.send( event = WalletEvent.ShowAlert( - state = getHideTokeAlertConfig(portfolioId, cryptoCurrencyStatus), + state = getHideTokeAlertConfig(userWalletId, cryptoCurrencyStatus), ), ) } } private suspend fun getHideTokeAlertConfig( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, ): WalletAlertState.DefaultAlert { val currency = cryptoCurrencyStatus.currency val isCryptoCurrencyCoinCouldHide = currency is CryptoCurrency.Coin && - !useCasesFacade.isCryptoCurrencyCoinCouldHide(portfolioId = portfolioId, cryptoCurrencyCoin = currency) + !isCryptoCurrencyCoinCouldHide(userWalletId = userWalletId, cryptoCurrencyCoin = currency) return if (isCryptoCurrencyCoinCouldHide) { WalletAlertState.DefaultAlert( title = resourceReference( @@ -351,14 +335,14 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( formatArgs = WrappedList(listOf(cryptoCurrencyStatus.currency.name)), ), message = resourceReference(R.string.token_details_hide_alert_message), - onConfirmClick = { onPerformHideToken(portfolioId, cryptoCurrencyStatus) }, + onConfirmClick = { onPerformHideToken(userWalletId, cryptoCurrencyStatus) }, ) } } - override fun onPerformHideToken(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onPerformHideToken(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { modelScope.launch(dispatchers.io) { - useCasesFacade.removeCurrencyUseCase(portfolioId, cryptoCurrencyStatus.currency) + removeCurrencyUseCase(userWalletId, cryptoCurrencyStatus.currency) .fold( ifLeft = { walletEventSender.send( @@ -366,7 +350,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) }, ifRight = { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = portfolioId.userWalletId)) + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) }, ) } @@ -399,7 +383,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } override fun onBuyClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) { @@ -415,7 +399,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( appRouter.push( AppRoute.Onramp( - portfolioId = portfolioId, + userWalletId = userWalletId, currency = cryptoCurrencyStatus.currency, source = OnrampSource.TOKEN_LONG_TAP, ), @@ -424,7 +408,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( override fun onSwapClick( cryptoCurrencyStatus: CryptoCurrencyStatus, - portfolioId: PortfolioId, + userWalletId: UserWalletId, unavailabilityReason: ScenarioUnavailabilityReason, ) { analyticsEventHandler.send( @@ -447,12 +431,12 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch { saveViewedYieldSupplyWarningUseCase(cryptoCurrencyStatus.currency.name) stateHolder.hideBottomSheet() - navigateToSwap(cryptoCurrencyStatus, portfolioId) + navigateToSwap(cryptoCurrencyStatus, userWalletId) } }, ) } else { - navigateToSwap(cryptoCurrencyStatus, portfolioId) + navigateToSwap(cryptoCurrencyStatus, userWalletId) } } } @@ -493,15 +477,15 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } - override fun onStakeClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = portfolioId.userWalletId)) + override fun onStakeClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) { + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) modelScope.launch { val cryptoCurrency = cryptoCurrencyStatus.currency appRouter.push( AppRoute.Staking( - portfolioId = portfolioId, + userWalletId = userWalletId, cryptoCurrencyId = cryptoCurrency.id, yieldId = yield?.id ?: return@launch, ), @@ -519,15 +503,19 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( override fun onMultiWalletSwapClick(userWalletId: UserWalletId) { val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return - val tokenListState = selectedWallet.tokensListState as? WalletTokensListState.ContentState.Content ?: return + val tokenListState = selectedWallet.tokensListState - if (tokenListState.items.count { it is TokensListItemUM.Token } < 2) { - handleError( - alertState = WalletAlertState.InsufficientTokensCountForSwapping, - eventCreator = MainScreenAnalyticsEvent::ButtonSwap, + when (tokenListState) { + is WalletTokensListState.ContentState.Content -> checkSwapCryptoAvailability( + tokenCount = tokenListState.items.count { it is TokensListItemUM.Token }, ) - - return + is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability( + tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } }, + ) + WalletTokensListState.ContentState.Loading, + WalletTokensListState.ContentState.Locked, + WalletTokensListState.Empty, + -> return } modelScope.launch { @@ -766,21 +754,21 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus) } - private fun navigateToSend(cryptoCurrencyStatus: CryptoCurrencyStatus, portfolioId: PortfolioId) { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = portfolioId.userWalletId)) + private fun navigateToSend(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) val route = AppRoute.Send( currency = cryptoCurrencyStatus.currency, - portfolioId = portfolioId, + userWalletId = userWalletId, ) appRouter.push(route) } - private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, portfolioId: PortfolioId) { + private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { appRouter.push( AppRoute.Swap( currencyFrom = cryptoCurrencyStatus.currency, - portfolioId = portfolioId, + userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.LongTap.value, ), ) @@ -808,4 +796,15 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) } } + + private fun checkSwapCryptoAvailability(tokenCount: Int) { + if (tokenCount < 2) { + handleError( + alertState = WalletAlertState.InsufficientTokensCountForSwapping, + eventCreator = MainScreenAnalyticsEvent::ButtonSwap, + ) + + return + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index d1e7c8cba5..937ae92eea 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -365,7 +365,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( userWalletId = userWallet.walletId, currency = cryptoCurrency, source = OnrampSource.SEPA_BANNER, - launchSepa = true, + shouldLaunchSepa = true, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index cd5f108efd..50569e441a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -19,6 +19,7 @@ import com.tangem.feature.wallet.presentation.common.WalletPreviewData.topBarCon import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList internal object WalletScreenPreviewData { private val tokenItemState = TokenItemState.Content( @@ -86,15 +87,17 @@ internal object WalletScreenPreviewData { private val portfolioContentState = WalletTokensListState.ContentState.PortfolioContent( items = persistentListOf( TokensListItemUM.Portfolio( - tokens = textContentTokensState.items.filterIsInstance(), + tokens = textContentTokensState.items.filterIsInstance().toPersistentList(), isExpanded = false, - state = AccountItemPreviewData.accountItem + isCollapsable = true, + tokenItemUM = AccountItemPreviewData.accountItem .copy(iconState = AccountItemPreviewData.accountLetterIcon), ), TokensListItemUM.Portfolio( - tokens = textContentTokensState.items.filterIsInstance(), + tokens = textContentTokensState.items.filterIsInstance().toPersistentList(), isExpanded = true, - state = AccountItemPreviewData.accountItem, + isCollapsable = true, + tokenItemUM = AccountItemPreviewData.accountItem, ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 95c0458d24..f2f41882cc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -3,11 +3,9 @@ package com.tangem.feature.wallet.presentation.router import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRoute.ManageTokens.Source import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.url.UrlOpener -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -67,12 +65,12 @@ internal class DefaultWalletRouter @Inject constructor( urlOpener.openUrl(url) } - override fun openTokenDetails(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) { + override fun openTokenDetails(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) { val networkAddress = currencyStatus.value.networkAddress if (networkAddress != null && networkAddress.defaultAddress.value.isNotEmpty()) { router.push( AppRoute.CurrencyDetails( - portfolioId = portfolioId, + userWalletId = userWalletId, currency = currencyStatus.currency, ), ) @@ -87,10 +85,6 @@ internal class DefaultWalletRouter @Inject constructor( return router.stack.lastOrNull() is AppRoute.Wallet } - override fun openManageTokensScreen(userWalletId: UserWalletId) { - router.push(AppRoute.ManageTokens(Source.SETTINGS, userWalletId)) - } - override fun openScanFailedDialog(onTryAgain: () -> Unit) { reduxStateHolder.dispatchDialogShow(StateDialog.ScanFailsDialog(StateDialog.ScanFailsSource.MAIN, onTryAgain)) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 2ff741c7b5..4c6c7ba002 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -2,7 +2,6 @@ package com.tangem.feature.wallet.presentation.router import androidx.compose.runtime.Stable import com.arkivanov.decompose.router.slot.SlotNavigation -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -42,7 +41,7 @@ internal interface InnerWalletRouter { fun openUrl(url: String) /** Open token details screen */ - fun openTokenDetails(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) + fun openTokenDetails(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) /** Open stories screen */ fun openStoriesScreen() @@ -50,9 +49,6 @@ internal interface InnerWalletRouter { /** Is wallet last screen */ fun isWalletLastScreen(): Boolean - /** Open manage tokens screen */ - fun openManageTokensScreen(userWalletId: UserWalletId) - /** Open scan failed dialog */ fun openScanFailedDialog(onTryAgain: () -> Unit) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt index bf5b27d5ea..fe583f7183 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt @@ -14,7 +14,6 @@ import com.tangem.domain.analytics.model.WalletBalanceState import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency @@ -38,33 +37,41 @@ internal class TokenListAnalyticsSender @Inject constructor( private val mutex = Mutex() private val loadingTraces = mutableMapOf() - suspend fun send(displayedUiState: WalletState?, userWallet: UserWallet, tokenList: TokenList) { + suspend fun send( + displayedUiState: WalletState?, + userWallet: UserWallet, + totalFiatBalance: TotalFiatBalance, + flattenCurrencies: List, + ) { if (screenLifecycleProvider.isBackgroundState.value) return if (displayedUiState == null || displayedUiState.pullToRefreshConfig.isRefreshing) return - if (tokenList.totalFiatBalance is TotalFiatBalance.Loading) { - startLoadingTraceIfNeeded(userWallet.walletId, tokenList) + if (totalFiatBalance is TotalFiatBalance.Loading) { + startLoadingTraceIfNeeded(userWallet.walletId, flattenCurrencies) return } - if (isTerminalState(tokenList.totalFiatBalance)) { - stopLoadingTraceIfNeeded(userWallet.walletId, tokenList.totalFiatBalance) + if (isTerminalState(totalFiatBalance)) { + stopLoadingTraceIfNeeded(userWallet.walletId, totalFiatBalance) } - val currenciesStatuses = tokenList.flattenCurrencies() + val currenciesStatuses = flattenCurrencies - sendBalanceLoadedEventIfNeeded(tokenList.totalFiatBalance, currenciesStatuses) - sendToppedUpEventIfNeeded(userWallet, tokenList.totalFiatBalance, currenciesStatuses) + sendBalanceLoadedEventIfNeeded(totalFiatBalance, currenciesStatuses) + sendToppedUpEventIfNeeded(userWallet, totalFiatBalance, currenciesStatuses) sendUnreachableNetworksEventIfNeeded(currenciesStatuses) sendTokenBalancesIfNeeded(currenciesStatuses) } - private suspend fun startLoadingTraceIfNeeded(userWalletId: UserWalletId, tokenList: TokenList) { + private suspend fun startLoadingTraceIfNeeded( + userWalletId: UserWalletId, + flattenCurrencies: List, + ) { mutex.withLock { if (!loadingTraces.containsKey(userWalletId)) { val trace = FirebasePerformance.getInstance().newTrace(BALANCE_LOADED_TRACE_NAME) trace.start() - trace.putAttribute(TOKENS_COUNT, tokenList.flattenCurrencies().size.toString()) + trace.putAttribute(TOKENS_COUNT, flattenCurrencies.size.toString()) loadingTraces[userWalletId] = trace } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 2d5684a5c0..be35057603 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -9,17 +9,16 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.StatusSource 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.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.onramp.GetOnrampCountryUseCase @@ -45,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject @@ -70,72 +70,81 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow> { val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver - val tokenListFlow = if (accountDependencies.accountsFeatureToggles.isFeatureEnabled) { + val accountStatusList by lazy { val params = SingleAccountStatusListProducer.Params(userWallet.walletId) accountDependencies.singleAccountStatusListSupplier(params) - } else { - tokenListStore.getOrThrow(userWallet.walletId) + .map { it.totalFiatBalance to it.flattenCurrencies() } + .map { Lce.Content(it) } } + + fun tokenListFlow(): LceFlow>> { + return if (accountDependencies.accountsFeatureToggles.isFeatureEnabled) { + accountStatusList + } else { + runCatching { tokenListStore.getOrThrow(userWallet.walletId) } + .map { result -> result.map { lce -> lce.map { it.totalFiatBalance to it.flattenCurrencies() } } } + .getOrNull() + // in case of runtime change ft in tester menu + ?: accountStatusList + } + } + + // val params = SingleAccountStatusListProducer.Params(userWallet.walletId) + // val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params) return combine( - tokenListFlow, + // todo account just use it, after delete accountsFeatureToggles + // accountStatusListFlow, isReadyToShowRateAppUseCase(), isNeedToBackupUseCase(userWallet.walletId), seedPhraseNotificationUseCase(userWalletId = userWallet.walletId), shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Referral), shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Sepa), notificationsRepository.getShouldShowNotification(NotificationId.EnablePushesReminderNotification.key), - ) { array -> - val totalFiatBalance: Lce - val flattenCurrencies: Lce> - if (accountDependencies.accountsFeatureToggles.isFeatureEnabled) { - val accountStatusList = array[0] as AccountStatusList - totalFiatBalance = Lce.Content(accountStatusList.totalFiatBalance) - flattenCurrencies = Lce.Content(accountStatusList.flattenCurrencies()) - } else { - val maybeTokenList = array[0] as Lce - totalFiatBalance = maybeTokenList.map { it.totalFiatBalance } - flattenCurrencies = maybeTokenList.map { it.flattenCurrencies() } + ) { array -> array } + .combine(tokenListFlow()) { array, any: Any -> arrayOf(any).plus(elements = array) } + .map { array -> + val lceTokens = array[0] as Lce>> + val totalFiatBalance = lceTokens.map { it.first } + val flattenCurrencies = lceTokens.map { it.second } + val isReadyToShowRating = array[1] as Boolean + val isNeedToBackup = array[2] as Boolean + val seedPhraseIssueStatus = array[3] as SeedPhraseNotificationsStatus + val shouldShowReferralPromo = array[4] as Boolean + val shouldShowSepaBanner = array[5] as Boolean + val shouldShowEnablePushesReminderNotification = array[6] as Boolean + + buildList { + addUsedOutdatedDataNotification(totalFiatBalance) + + addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents) + + addFinishWalletActivationNotification(userWallet, totalFiatBalance, clickIntents) + + addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) + + addSepaPromoNotification(userWallet, clickIntents, shouldShowSepaBanner) + + addInformationalNotifications(userWallet, cardTypesResolver, flattenCurrencies, clickIntents) + + addWarningNotifications(cardTypesResolver, flattenCurrencies, isNeedToBackup, clickIntents) + + addPushReminderNotification( + clickIntents = clickIntents, + shouldShowPushReminderBanner = shouldShowEnablePushesReminderNotification && + !notificationsRepository.isUserAllowToSubscribeOnPushNotifications(), + ) + + addYieldSupplyNotifications(flattenCurrencies) + + val hasCriticalOrWarning = any { notification -> + notification is WalletNotification.Critical || notification is WalletNotification.Warning + } + + if (!hasCriticalOrWarning) { + addRateTheAppNotification(isReadyToShowRating, clickIntents) + } + }.toImmutableList() } - - val isReadyToShowRating = array[1] as Boolean - val isNeedToBackup = array[2] as Boolean - val seedPhraseIssueStatus = array[3] as SeedPhraseNotificationsStatus - val shouldShowReferralPromo = array[4] as Boolean - val shouldShowSepaBanner = array[5] as Boolean - val shouldShowEnablePushesReminderNotification = array[6] as Boolean - - buildList { - addUsedOutdatedDataNotification(totalFiatBalance) - - addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents) - - addFinishWalletActivationNotification(userWallet, totalFiatBalance, clickIntents) - - addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) - - addSepaPromoNotification(userWallet, clickIntents, shouldShowSepaBanner) - - addInformationalNotifications(userWallet, cardTypesResolver, flattenCurrencies, clickIntents) - - addWarningNotifications(cardTypesResolver, flattenCurrencies, isNeedToBackup, clickIntents) - - addPushReminderNotification( - clickIntents = clickIntents, - shouldShowPushReminderBanner = shouldShowEnablePushesReminderNotification && - !notificationsRepository.isUserAllowToSubscribeOnPushNotifications(), - ) - - addYieldSupplyNotifications(flattenCurrencies) - - val hasCriticalOrWarning = any { notification -> - notification is WalletNotification.Critical || notification is WalletNotification.Warning - } - - if (!hasCriticalOrWarning) { - addRateTheAppNotification(isReadyToShowRating, clickIntents) - } - }.toImmutableList() - } } private fun MutableList.addUsedOutdatedDataNotification( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt index 9565609246..230eac3a61 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt @@ -50,7 +50,7 @@ internal class SetCryptoCurrencyActionsTransformer( dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, onClick = { clickIntents.onBuyClick( - portfolioId = portfolioId, + userWalletId = portfolioId.userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, unavailabilityReason = action.unavailabilityReason, ) @@ -62,7 +62,10 @@ internal class SetCryptoCurrencyActionsTransformer( enabled = true, dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, onClick = { - clickIntents.onReceiveClick(portfolioId, cryptoCurrencyStatus = cryptoCurrencyStatus) + clickIntents.onReceiveClick( + portfolioId.userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) }, onLongClick = { clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus = cryptoCurrencyStatus) @@ -87,7 +90,7 @@ internal class SetCryptoCurrencyActionsTransformer( dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, onClick = { clickIntents.onSendClick( - portfolioId = portfolioId, + userWalletId = portfolioId.userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, unavailabilityReason = action.unavailabilityReason, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt index 18974cedeb..0c3ac24ecd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt @@ -9,7 +9,6 @@ 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.card.common.util.getCardsCount -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.visa.exception.RefreshTokenExpiredException @@ -153,7 +152,7 @@ internal class SetVisaInfoTransformer( dimContent = false, onClick = { clickIntents.onReceiveClick( - portfolioId = PortfolioId(userWalletId), // todo account Visa use Main account? + userWalletId = userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, event = MainScreenAnalyticsEvent.ButtonReceive, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt similarity index 50% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt index 9e367377ba..4af0c5bdc7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt @@ -1,33 +1,28 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers -import com.tangem.common.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.MainScreenCustomerInfo import com.tangem.domain.pay.model.OrderStatus.CANCELED -import com.tangem.domain.pay.model.OrderStatus.NOT_ISSUED +import com.tangem.domain.pay.model.OrderStatus.UNKNOWN import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueAvailableState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueProgressState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createKycInProgressState import java.util.Currency -internal class TangemPayStateTransformer( +internal class TangemPayInitialStateTransformer( private val value: MainScreenCustomerInfo? = null, - private val onIssueOrderClick: () -> Unit = {}, - private val onContinueKycClick: () -> Unit = {}, + private val onClickIssue: () -> Unit = {}, + private val onClickKyc: () -> Unit = {}, private val openDetails: (customerWalletAddress: String, cardNumberEnd: String) -> Unit = { _, _ -> }, - private val issueProgressState: Boolean = false, - private val issueState: Boolean = false, ) : WalletScreenStateTransformer { override fun transform(prevState: WalletScreenState): WalletScreenState { - val tangemPayState = when { - issueProgressState -> createIssueProgressState() - issueState -> createIssueState() - else -> createInitialState() - } + val tangemPayState = createInitialState() return prevState.copy(tangemPayState = tangemPayState) } @@ -35,35 +30,13 @@ internal class TangemPayStateTransformer( val cardInfo = value?.info?.cardInfo return when { value == null -> TangemPayState.Empty - !value.info.isKycApproved() -> createKycInProgressState(onContinueKycClick) + !value.info.isKycApproved -> createKycInProgressState(onClickKyc) cardInfo != null -> getCardInfoState(cardInfo) - value.orderStatus == NOT_ISSUED || value.orderStatus == CANCELED -> createIssueState() + value.orderStatus == UNKNOWN || value.orderStatus == CANCELED -> createIssueAvailableState(onClickIssue) else -> createIssueProgressState() } } - private fun createIssueProgressState(): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_issue_card_notification_title), - buttonText = TextReference.EMPTY, - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = {}, - showProgress = true, - ) - - private fun createIssueState() = Progress( - title = TextReference.Res(R.string.tangempay_issue_card_notification_title), - buttonText = TextReference.Res(R.string.common_continue), - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = onIssueOrderClick, - ) - - private fun createKycInProgressState(onContinueKycClick: () -> Unit): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_title), - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = onContinueKycClick, - ) - private fun getCardInfoState(cardInfo: CardInfo): TangemPayState = TangemPayState.Card( lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"), balanceText = TextReference.Str(getBalanceText(cardInfo)), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueAvailableStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueAvailableStateTransformer.kt new file mode 100644 index 0000000000..3f66ab33ad --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueAvailableStateTransformer.kt @@ -0,0 +1,12 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueAvailableState + +internal class TangemPayIssueAvailableStateTransformer( + private val onClickIssue: () -> Unit = {}, +) : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState = + prevState.copy(tangemPayState = createIssueAvailableState(onClickIssue)) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueProgressStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueProgressStateTransformer.kt new file mode 100644 index 0000000000..b7bc76806e --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueProgressStateTransformer.kt @@ -0,0 +1,10 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueProgressState + +internal class TangemPayIssueProgressStateTransformer : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState = + prevState.copy(tangemPayState = createIssueProgressState()) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt index 6a88f70616..72e55cfb23 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt @@ -3,9 +3,9 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.models.PortfolioId 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.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.feature.wallet.child.wallet.model.intents.WalletCurrencyActionsClickIntents @@ -18,10 +18,11 @@ import kotlinx.collections.immutable.toImmutableList internal class MultiWalletCurrencyActionsConverter( private val userWallet: UserWallet, - private val portfolioId: PortfolioId, private val clickIntents: WalletCurrencyActionsClickIntents, ) : Converter> { + private val userWalletId: UserWalletId = userWallet.walletId + override fun convert(value: TokenActionsState): ImmutableList { return value.states .filterIfSingleWithToken() @@ -56,17 +57,17 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.Buy -> { title = resourceReference(R.string.common_buy) icon = R.drawable.ic_plus_24 - action = { clickIntents.onBuyClick(portfolioId, cryptoCurrencyStatus, noneReason) } + action = { clickIntents.onBuyClick(userWalletId, cryptoCurrencyStatus, noneReason) } } is TokenActionsState.ActionState.Receive -> { title = resourceReference(R.string.common_receive) icon = R.drawable.ic_arrow_down_24 - action = { clickIntents.onReceiveClick(portfolioId, cryptoCurrencyStatus) } + action = { clickIntents.onReceiveClick(userWalletId, cryptoCurrencyStatus) } } is TokenActionsState.ActionState.Stake -> { title = resourceReference(R.string.common_stake) icon = R.drawable.ic_staking_24 - action = { clickIntents.onStakeClick(portfolioId, cryptoCurrencyStatus, actionsState.yield) } + action = { clickIntents.onStakeClick(userWalletId, cryptoCurrencyStatus, actionsState.yield) } } is TokenActionsState.ActionState.Sell -> { title = resourceReference(R.string.common_sell) @@ -76,7 +77,7 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.Send -> { title = resourceReference(R.string.common_send) icon = R.drawable.ic_arrow_up_24 - action = { clickIntents.onSendClick(portfolioId, cryptoCurrencyStatus, noneReason) } + action = { clickIntents.onSendClick(userWalletId, cryptoCurrencyStatus, noneReason) } } is TokenActionsState.ActionState.Swap -> { title = resourceReference(R.string.swapping_swap_action) @@ -84,7 +85,7 @@ internal class MultiWalletCurrencyActionsConverter( action = { clickIntents.onSwapClick( cryptoCurrencyStatus = cryptoCurrencyStatus, - portfolioId = portfolioId, + userWalletId = userWalletId, unavailabilityReason = noneReason, ) } @@ -92,12 +93,12 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.CopyAddress -> { title = resourceReference(R.string.common_copy_address) icon = R.drawable.ic_copy_24 - action = { clickIntents.onCopyAddressClick(portfolioId, cryptoCurrencyStatus) } + action = { clickIntents.onCopyAddressClick(userWalletId, cryptoCurrencyStatus) } } is TokenActionsState.ActionState.HideToken -> { title = resourceReference(R.string.token_details_hide_token) icon = R.drawable.ic_hide_24 - action = { clickIntents.onHideTokensClick(portfolioId, cryptoCurrencyStatus) } + action = { clickIntents.onHideTokensClick(userWalletId, cryptoCurrencyStatus) } } is TokenActionsState.ActionState.Analytics -> { title = resourceReference(R.string.common_analytics) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 931921c425..eee1c4776a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -2,7 +2,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter -import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.resourceReference @@ -14,7 +13,6 @@ import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.tokenlist.TokenList.GroupedByNetwork.NetworkGroup @@ -36,23 +34,22 @@ internal class TokenListStateConverter( private val params: TokenConverterParams, private val selectedWallet: UserWallet, private val clickIntents: WalletClickIntents, - private val apyMap: Map = emptyMap(), + private val apyMap: Map, ) : Converter { private val onTokenClick: (accountId: AccountId?, currencyStatus: CryptoCurrencyStatus) -> Unit = { accountId, currencyStatus -> - val id = accountId?.let { PortfolioId(accountId) } ?: PortfolioId(selectedWallet.walletId) - clickIntents.onTokenItemClick(id, currencyStatus) + clickIntents.onTokenItemClick(selectedWallet.walletId, currencyStatus) } private val onTokenLongClick: (accountId: AccountId?, currencyStatus: CryptoCurrencyStatus) -> Unit = { accountId, currencyStatus -> - val id = accountId?.let { PortfolioId(accountId) } ?: PortfolioId(selectedWallet.walletId) - clickIntents.onTokenItemLongClick(id, currencyStatus) + clickIntents.onTokenItemLongClick(selectedWallet.walletId, currencyStatus) } private fun tokenStatusConverter(accountId: AccountId? = null) = TokenItemStateConverter( appCurrency = appCurrency, + apyMap = apyMap, onItemClick = { _, status -> onTokenClick(accountId, status) }, onItemLongClick = { _, status -> onTokenLongClick(accountId, status) }, ) @@ -112,9 +109,10 @@ internal class TokenListStateConverter( is WalletTokensListState.Empty -> listOf() } return TokensListItemUM.Portfolio( - state = accountItem, + tokenItemUM = accountItem, isExpanded = isExtend, - tokens = items.filterIsInstance(), + isCollapsable = true, + tokens = items.filterIsInstance().toPersistentList(), ) } @@ -168,55 +166,13 @@ internal class TokenListStateConverter( tokenConverter: TokenItemStateConverter, token: CryptoCurrencyStatus, ): List { - val tokenItemState = tokenConverter.convert(token).withEarnApyBadge(token) + val tokenItemState = tokenConverter.convert(token) add(TokensListItemUM.Token(tokenItemState)) return this } - private fun TokenItemState.withEarnApyBadge(cryptoCurrencyStatus: CryptoCurrencyStatus): TokenItemState { - val apy: String? = resolveEarnApy(cryptoCurrencyStatus) - - val shouldApply = apy != null && this.titleState is TokenItemState.TitleState.Content - - return if (!shouldApply) { - this - } else { - val contentTitle = this.titleState as TokenItemState.TitleState.Content - val newTitle = contentTitle.copy( - earnApy = resourceReference( - R.string.yield_module_earn_badge, - wrappedList(apy), - ), - ) - - when (this) { - is TokenItemState.Content -> this.copy(titleState = newTitle) - is TokenItemState.Unreachable -> this.copy(titleState = newTitle) - is TokenItemState.NoAddress -> this.copy(titleState = newTitle) - is TokenItemState.Loading -> this.copy(titleState = newTitle) - is TokenItemState.Draggable -> this.copy(titleState = newTitle) - is TokenItemState.Locked -> this - } - } - } - - private fun resolveEarnApy(cryptoCurrencyStatus: CryptoCurrencyStatus): String? { - if (apyMap.isEmpty()) return null - - val isYieldSupplyActive = (cryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded) - ?.yieldSupplyStatus?.isActive == true - if (isYieldSupplyActive) return null - - val contract = (cryptoCurrencyStatus.currency as? CryptoCurrency.Token) - ?.contractAddress - ?.lowercase() ?: return null - - val yieldSupplyKey = "${cryptoCurrencyStatus.currency.network.backendId}_$contract" - return apyMap[yieldSupplyKey] - } - private fun getOrganizeTokensButtonState(tokenList: TokenList): WalletOrganizeTokensButtonConfig? { val currenciesSize = when (tokenList) { TokenList.Empty -> return null diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/util/TangemPayStateCreator.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/util/TangemPayStateCreator.kt new file mode 100644 index 0000000000..f8b3293d44 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/util/TangemPayStateCreator.kt @@ -0,0 +1,31 @@ +package com.tangem.feature.wallet.presentation.wallet.state.util + +import com.tangem.common.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState +import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress + +internal object TangemPayStateCreator { + + fun createKycInProgressState(onClickKyc: () -> Unit): TangemPayState = Progress( + title = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_title), + buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), + iconRes = R.drawable.ic_promo_kyc_36, + onButtonClick = onClickKyc, + ) + + fun createIssueAvailableState(onClickIssue: () -> Unit) = Progress( + title = TextReference.Res(R.string.tangempay_issue_card_notification_title), + buttonText = TextReference.Res(R.string.common_continue), + iconRes = R.drawable.ic_tangem_pay_promo_card_36, + onButtonClick = onClickIssue, + ) + + fun createIssueProgressState(): TangemPayState = Progress( + title = TextReference.Res(R.string.tangempay_issue_card_notification_title), + buttonText = TextReference.EMPTY, + iconRes = R.drawable.ic_tangem_pay_promo_card_36, + onButtonClick = {}, + showProgress = true, + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt index b9b464642d..80068f621c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt @@ -8,7 +8,9 @@ import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.models.PortfolioId +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase @@ -63,7 +65,10 @@ internal abstract class BasicTokenListSubscriber : WalletSubscriber() { flow = tokenListFlow(coroutineScope) .onEach { maybeTokenList -> coroutineScope.launch { - sendTokenListAnalytics(maybeTokenList) + sendTokenListAnalytics( + flattenCurrencies = maybeTokenList.getOrNull()?.flattenCurrencies(), + totalFiatBalance = maybeTokenList.getOrNull()?.totalFiatBalance, + ) }.saveIn(sendAnalyticsJobHolder) } .distinctUntilChanged() @@ -140,12 +145,14 @@ internal abstract class BasicTokenListSubscriber : WalletSubscriber() { private fun createAccountListFlow(coroutineScope: CoroutineScope): Flow<*> = combine( flow = accountListFlow(coroutineScope) - // todo account analytics for account total balance - /*.onEach { maybeTokenList -> + .onEach { accountStatusList -> coroutineScope.launch { - sendTokenListAnalytics(maybeTokenList) + sendTokenListAnalytics( + flattenCurrencies = accountStatusList.flattenCurrencies(), + totalFiatBalance = accountStatusList.totalFiatBalance, + ) }.saveIn(sendAnalyticsJobHolder) - }*/ + } .distinctUntilChanged() .onEach { accountList -> // todo account see[onAccountListReceived] @@ -207,13 +214,17 @@ internal abstract class BasicTokenListSubscriber : WalletSubscriber() { } } - private suspend fun sendTokenListAnalytics(maybeTokenList: Lce) { + private suspend fun sendTokenListAnalytics( + flattenCurrencies: List?, + totalFiatBalance: TotalFiatBalance?, + ) { val displayedState = stateHolder.getWalletStateIfSelected(userWallet.walletId) tokenListAnalyticsSender.send( displayedUiState = displayedState, userWallet = userWallet, - tokenList = maybeTokenList.getOrNull() ?: return, + flattenCurrencies = flattenCurrencies ?: return, + totalFiatBalance = totalFiatBalance ?: return, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt index 140a458bfc..95126a8582 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.tokenlist.PortfolioListItem import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM @@ -59,6 +60,7 @@ internal fun LazyListScope.portfolioTokensList( contentType = { _, item -> item::class.java }, itemContent = { tokenIndex, token -> val indexWithHeader = tokenIndex.inc() + val lastIndex = tokens.lastIndex.inc() val isPreview = LocalInspectionMode.current val appear = remember { MutableTransitionState(isPreview).apply { targetState = true } @@ -69,11 +71,12 @@ internal fun LazyListScope.portfolioTokensList( .animateItem() .roundedShapeItemDecoration( currentIndex = indexWithHeader, - lastIndex = tokens.lastIndex.inc(), + lastIndex = lastIndex, backgroundColor = TangemTheme.colors.background.primary, ), visibleState = appear, ) { + val modifier = if (indexWithHeader == lastIndex) Modifier.padding(bottom = 8.dp) else Modifier PortfolioTokensListItem( state = token, isBalanceHidden = isBalanceHidden, @@ -114,10 +117,15 @@ private fun LazyListScope.portfolioItem( modifier = anchorModifier, visibleState = appear, ) { + val modifier = if (portfolio.tokens.isEmpty()) { + Modifier.padding(vertical = 8.dp) + } else { + Modifier.padding(top = 8.dp) + } PortfolioListItem( state = portfolio, isBalanceHidden = isBalanceHidden, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + modifier = modifier, ) } } else { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/utils/WalletFeatureUseCasesFacade.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/utils/WalletFeatureUseCasesFacade.kt deleted file mode 100644 index 8ab07acad0..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/utils/WalletFeatureUseCasesFacade.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.utils - -import com.tangem.domain.models.PortfolioId -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.IsCryptoCurrencyCoinCouldHideUseCase -import com.tangem.domain.tokens.RemoveCurrencyUseCase -import javax.inject.Inject - -class WalletFeatureUseCasesFacade @Inject constructor( - private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase, - private val removeCurrencyUseCase: RemoveCurrencyUseCase, -) { - - suspend fun isCryptoCurrencyCoinCouldHide(portfolioId: PortfolioId, cryptoCurrencyCoin: CryptoCurrency.Coin) = - when (portfolioId) { - is PortfolioId.Account -> TODO("account") - is PortfolioId.Wallet -> isCryptoCurrencyCoinCouldHide(portfolioId.userWalletId, cryptoCurrencyCoin) - } - - suspend fun removeCurrencyUseCase(portfolioId: PortfolioId, currency: CryptoCurrency) = when (portfolioId) { - is PortfolioId.Account -> TODO("account") - is PortfolioId.Wallet -> removeCurrencyUseCase(portfolioId.userWalletId, currency) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt index 1b4e9c358f..65c3ffbbe7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt @@ -130,6 +130,11 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( balance = balance, isBalanceHidden = balanceHidingSettings.isBalanceHidden, artwork = artworks[userWallet.walletId], + endIcon = if (isAuthMode.not() && userWallet is UserWallet.Hot && !userWallet.backedUp) { + UserWalletItemUM.EndIcon.Warning + } else { + UserWalletItemUM.EndIcon.None + }, isAuthMode = isAuthMode, ) .convert(userWallet) diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts index 0bf16b6415..1d3a97b3a6 100644 --- a/features/walletconnect/impl/build.gradle.kts +++ b/features/walletconnect/impl/build.gradle.kts @@ -16,13 +16,16 @@ dependencies { implementation(projects.features.walletconnect.api) implementation(projects.features.sendV2.api) - /** Core */ - implementation(projects.core.configToggles) - implementation(projects.core.decompose) - implementation(projects.core.ui) + /** Common */ implementation(projects.common.routing) implementation(projects.common.ui) + + /** Core */ implementation(projects.core.analytics) + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.ui) /** Domain models */ implementation(projects.domain.appCurrency.models) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt index f079b379c0..8afd70342c 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt @@ -110,7 +110,9 @@ private fun EmptyConnectionsBlock(onNewConnectionClick: () -> Unit, modifier: Mo Image( painter = painterResource(R.drawable.img_wallet_connect_76), contentDescription = "Wallet Connect", - modifier = Modifier.size(76.dp), + modifier = Modifier + .size(76.dp) + .testTag(WalletConnectScreenTestTags.WALLET_CONNECT_IMAGE), ) Text( modifier = Modifier.padding(top = TangemTheme.dimens.spacing24), diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt index 0bdad0e725..57aef929c4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt @@ -11,4 +11,5 @@ internal data class WcSpendAllowanceUM( val tokenSymbol: String, val tokenImageUrl: String?, val networkIconRes: Int?, + val onLearnMoreClicked: () -> Unit, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index 8d36bbe2c6..8b201362c2 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -16,6 +16,7 @@ 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.clipboard.ClipboardManager import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2.Icon.Type @@ -55,6 +56,7 @@ import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransacti import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes import com.tangem.features.walletconnect.transaction.ui.blockaid.WcSendAndReceiveBlockAidUiConverter import com.tangem.features.walletconnect.utils.WcNotificationsFactory +import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -74,11 +76,11 @@ internal class WcSendTransactionModel @Inject constructor( private val clipboardManager: ClipboardManager, private val useCaseFactory: WcRequestUseCaseFactory, private val converter: WcSendTransactionUMConverter, - private val blockAidUiConverter: WcSendAndReceiveBlockAidUiConverter, private val getFeeUseCase: GetFeeUseCase, private val getNetworkCoinUseCase: GetNetworkCoinStatusUseCase, private val notificationsFactory: WcNotificationsFactory, private val analytics: AnalyticsEventHandler, + private val urlOpener: UrlOpener, ) : Model(), WcCommonTransactionModel, FeeSelectorModelCallback { private val params = paramsContainer.require() @@ -94,6 +96,7 @@ internal class WcSendTransactionModel @Inject constructor( private var signState: WcSignState<*> by Delegates.notNull() private var wcApproval: WcApproval? = null private var sign: () -> Unit = {} + private val blockAidUiConverter = WcSendAndReceiveBlockAidUiConverter() private val feeReloadState = MutableStateFlow(false) private val signatureReceivedAnalyticsSendState = MutableStateFlow(false) private val securityStatusState = @@ -243,8 +246,9 @@ internal class WcSendTransactionModel @Inject constructor( val blockAidState = when (securityCheck) { is Lce.Content -> blockAidUiConverter.convert( WcSendAndReceiveBlockAidUiConverter.Input( - securityCheck.content.result, - if (isApproval) wcApproval?.getAmount() else null, + result = securityCheck.content.result, + approvedAmount = if (isApproval) wcApproval?.getAmount() else null, + onApproveLearnMoreClick = ::onApproveLearnMoreClick, ), ) is Lce.Error -> WcSendReceiveTransactionCheckResultsUM(isLoading = false) @@ -291,6 +295,15 @@ internal class WcSendTransactionModel @Inject constructor( stackNavigation.pop() } + private fun onApproveLearnMoreClick() { + val code = SupportedLanguages.getCurrentSupportedLanguageCode() + .takeIf { it == SupportedLanguages.RUSSIAN } + ?: SupportedLanguages.ENGLISH + + val url = "https://tangem.com/$code/blog/post/give-revoke-permission/" + urlOpener.openUrl(url) + } + private fun isMultipleSignRequired(useCase: WcSignUseCase<*>): Boolean { return if (useCase is SignRequirements) { useCase.isMultipleSignRequired() diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt index 60c4fc70c2..a4e431c1a1 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt @@ -255,6 +255,7 @@ private class WcCustomAllowanceStateProvider : CollectionPreviewParameterProvide amountValue = BigDecimal("100"), tokenSymbol = "ETH", isUnlimited = false, + onLearnMoreClicked = {}, ), ), ) \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt index 2b7aca4340..92dbcdd661 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt @@ -3,25 +3,29 @@ package com.tangem.features.walletconnect.transaction.ui.blockaid import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink 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.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.isNullOrEmpty -import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.walletconnect.impl.R +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.features.walletconnect.transaction.entity.blockaid.BlockAidNotificationUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangesUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM import com.tangem.features.walletconnect.transaction.ui.approve.WcSpendAllowanceItem import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal @Composable internal fun TransactionCheckResultsItem( @@ -29,11 +33,7 @@ internal fun TransactionCheckResultsItem( onClickAllowToSpend: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { if (item.isLoading) { WcEstimatedWalletChangesLoadingItem() } else { @@ -44,6 +44,10 @@ internal fun TransactionCheckResultsItem( WcEstimatedWalletChangesItem(item.estimatedWalletChanges) } else if (item.spendAllowance != null) { WcSpendAllowanceItem(item.spendAllowance, onClickAllowToSpend) + ApproveDescription( + modifier = Modifier.padding(bottom = 6.dp, start = 12.dp, end = 12.dp), + onLearnMoreClick = item.spendAllowance.onLearnMoreClicked, + ) } else if (!item.additionalNotification.isNullOrEmpty()) { WcEstimatedWalletChangesNotificationItem(description = item.additionalNotification) } else { @@ -53,6 +57,29 @@ internal fun TransactionCheckResultsItem( } } +@Composable +private fun ApproveDescription(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) { + val linkText = stringResourceSafe(R.string.common_learn_more) + val fullString = stringResourceSafe(R.string.wc_approve_description) + val defaultColor = TangemTheme.colors.text.tertiary + val linkColor = TangemTheme.colors.text.accent + Text( + modifier = modifier, + style = TangemTheme.typography.caption2, + text = buildAnnotatedString { + appendColored(fullString, defaultColor) + appendSpace() + withLink( + link = LinkAnnotation.Clickable( + tag = "WC_APPROVE_LEARN_MORE_TAG", + linkInteractionListener = { onLearnMoreClick() }, + ), + block = { appendColored(text = linkText, color = linkColor) }, + ) + }, + ) +} + @Composable @Preview(showBackground = true, device = Devices.PIXEL_7_PRO) @Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -95,5 +122,22 @@ private class TransactionCheckResultsItemProvider : PreviewParameterProvider { override fun convert(value: Input): WcEstimatedWalletChangeUM { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt index 3ef6afbf4c..e8777d39f3 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt @@ -20,15 +20,16 @@ import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal -import javax.inject.Inject private const val DECIMALS_AMOUNT = 2 @Suppress("CyclomaticComplexMethod", "LongMethod") -internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor( - private val estimatedWalletChangeUMConverter: WcEstimatedWalletChangeUMConverter, - private val spendAllowanceUMConverter: WcSpendAllowanceUMConverter, -) : Converter { +internal class WcSendAndReceiveBlockAidUiConverter : + Converter { + + private val estimatedWalletChangeUMConverter = WcEstimatedWalletChangeUMConverter() + private val spendAllowanceUMConverter = WcSpendAllowanceUMConverter() + override fun convert(value: Input): WcSendReceiveTransactionCheckResultsUM { val description = value.result.description?.let { if (it.isNotEmpty()) TextReference.Str(it) else null } val simulation = value.result.simulation @@ -118,7 +119,12 @@ internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor( when (data) { is SimulationData.SendAndReceive, SimulationData.NoWalletChangesDetected -> null is SimulationData.Approve -> value.approvedAmount?.let { - spendAllowanceUMConverter.convert(it) + spendAllowanceUMConverter.convert( + WcSpendAllowanceUMConverter.Input( + approvedAmount = it, + onLearnMoreClick = value.onApproveLearnMoreClick, + ), + ) } } }, @@ -128,6 +134,7 @@ internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor( data class Input( val result: CheckTransactionResult, val approvedAmount: WcApprovedAmount?, + val onApproveLearnMoreClick: () -> Unit, ) } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt index 4f7bf34268..e79abdf12d 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt @@ -6,24 +6,26 @@ import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.features.walletconnect.impl.R import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.utils.converter.Converter -import javax.inject.Inject -internal class WcSpendAllowanceUMConverter @Inject constructor() : Converter { +internal class WcSpendAllowanceUMConverter : Converter { - override fun convert(value: WcApprovedAmount): WcSpendAllowanceUM { - val amount = value.amount?.value ?: 0.0.toBigDecimal() - val isUnlimited = value.amount?.value == null + override fun convert(value: Input): WcSpendAllowanceUM { + val amount = value.approvedAmount.amount?.value ?: 0.0.toBigDecimal() + val isUnlimited = value.approvedAmount.amount?.value == null return WcSpendAllowanceUM( amountValue = amount, - amountText = if (value.amount?.value == null) { + amountText = if (value.approvedAmount.amount?.value == null) { TextReference.Res(R.string.wc_common_unlimited) } else { TextReference.Str(amount.amountText()) }, isUnlimited = isUnlimited, - tokenSymbol = value.amount?.currencySymbol ?: "", - tokenImageUrl = value.logoUrl, - networkIconRes = value.chainId?.toString()?.let { getActiveIconRes(it) }, + tokenSymbol = value.approvedAmount.amount?.currencySymbol ?: "", + tokenImageUrl = value.approvedAmount.logoUrl, + networkIconRes = value.approvedAmount.chainId?.toString()?.let { getActiveIconRes(it) }, + onLearnMoreClicked = value.onLearnMoreClick, ) } + + data class Input(val approvedAmount: WcApprovedAmount, val onLearnMoreClick: () -> Unit) } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt index a1d574e005..1f02216d53 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt @@ -37,6 +37,7 @@ import com.tangem.features.walletconnect.connections.entity.VerifiedDAppState import com.tangem.features.walletconnect.connections.ui.WcAppInfoItem import com.tangem.features.walletconnect.impl.R import com.tangem.features.walletconnect.transaction.components.PreviewFeeSelectorBlockComponent +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.features.walletconnect.transaction.entity.blockaid.BlockAidNotificationUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangesUM @@ -51,6 +52,7 @@ import com.tangem.features.walletconnect.transaction.ui.common.WcSmallTitleItem import com.tangem.features.walletconnect.transaction.ui.common.WcTransactionRequestButtons import com.tangem.features.walletconnect.transaction.ui.common.WcTransactionRequestItem import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal @Suppress("LongParameterList", "LongMethod") @Composable @@ -316,5 +318,34 @@ private class WcSendTransactionStateProvider : CollectionPreviewParameterProvide ), transactionValidationResult = ValidationResult.SAFE, ), + WcSendTransactionItemUM( + onDismiss = {}, + onSend = {}, + appInfo = WcTransactionAppInfoContentUM( + appName = "React App", + appIcon = "", + verifiedState = VerifiedDAppState.Verified {}, + appSubtitle = "react-app.walletconnect.com", + ), + estimatedWalletChanges = WcSendReceiveTransactionCheckResultsUM( + isLoading = false, + spendAllowance = WcSpendAllowanceUM( + amountValue = BigDecimal.ZERO, + isUnlimited = false, + amountText = stringReference("0.00 WPOL"), + tokenSymbol = "", + tokenImageUrl = "", + networkIconRes = 0, + onLearnMoreClicked = {}, + ), + ), + walletName = "Tangem 2.0", + networkInfo = WcNetworkInfoUM(name = "Ethereum", iconRes = R.drawable.img_eth_22), + feeState = WcTransactionFeeState.None, + address = "0xdac17f958d2ee523a2206206994597c13d831ec7", + sendEnabled = true, + feeErrorNotification = null, + transactionValidationResult = ValidationResult.SAFE, + ), ), ) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt index 14baa04a9a..d00eede2f9 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt @@ -5,6 +5,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal @Immutable internal sealed class YieldSupplyFeeUM { @@ -27,4 +28,5 @@ internal data class YieldSupplyActionUM( val yieldSupplyFeeUM: YieldSupplyFeeUM, val isPrimaryButtonEnabled: Boolean, val isTransactionSending: Boolean, + val maxFee: BigDecimal = BigDecimal.ZERO, ) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt index bfc0e7857d..abb21ee496 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt @@ -38,9 +38,8 @@ internal class DefaultYieldSupplyComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val yieldSupplyUM by model.uiState.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() - val isBalanceHidden by model.isBalanceHiddenFlow.collectAsStateWithLifecycle() - YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, isBalanceHidden = isBalanceHidden, modifier = modifier) + YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, modifier = modifier) bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt index de7a778530..30008334c4 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt @@ -6,7 +6,9 @@ import com.tangem.core.ui.extensions.TextReference @Immutable internal sealed class YieldSupplyUM { - data class Initial( + data object Initial : YieldSupplyUM() + + data class Available( val title: TextReference, val onClick: () -> Unit, ) : YieldSupplyUM() @@ -16,7 +18,8 @@ internal sealed class YieldSupplyUM { data object Unavailable : YieldSupplyUM() data class Content( - val rewardsBalance: TextReference, + val title: TextReference, + val subtitle: TextReference, val rewardsApy: TextReference, val onClick: () -> Unit, val isAllowedToSpend: Boolean, diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index d4c2c34325..5b5f8676da 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -7,21 +7,26 @@ import com.tangem.common.routing.AppRouter 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.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase -import com.tangem.features.yield.supply.api.YieldSupplyComponent +import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.DelayedWork import kotlinx.coroutines.CoroutineScope @@ -29,6 +34,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber +import com.tangem.utils.transformer.update import javax.inject.Inject import kotlin.properties.Delegates @@ -44,12 +50,15 @@ internal class YieldSupplyModel @Inject constructor( @DelayedWork private val coroutineScope: CoroutineScope, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, + private val yieldSupplyIsAvailableUseCase: YieldSupplyIsAvailableUseCase, + private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, + private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, ) : Model(), YieldSupplyClickIntents { private val params = paramsContainer.require() val uiState: StateFlow - field = MutableStateFlow(YieldSupplyUM.Loading) + field = MutableStateFlow(YieldSupplyUM.Initial) val bottomSheetNavigation: SlotNavigation = SlotNavigation() @@ -67,9 +76,20 @@ internal class YieldSupplyModel @Inject constructor( val isBalanceHiddenFlow: StateFlow field = MutableStateFlow(false) + private var lastYieldSupplyStatus: YieldSupplyStatus? = null + init { - subscribeOnCurrencyStatusUpdates() - subscribeOnBalanceHidden() + checkIfYieldSupplyIsAvailable() + } + + private fun checkIfYieldSupplyIsAvailable() { + modelScope.launch(dispatchers.io) { + val isAvailable = yieldSupplyIsAvailableUseCase(params.userWalletId, params.cryptoCurrency) + if (isAvailable) { + subscribeOnCurrencyStatusUpdates() + subscribeOnBalanceHidden() + } + } } private fun subscribeOnCurrencyStatusUpdates() { @@ -86,7 +106,7 @@ internal class YieldSupplyModel @Inject constructor( maybeCryptoCurrency.fold( ifRight = { cryptoCurrencyStatus -> cryptoCurrencyStatusFlow.update { cryptoCurrencyStatus } - onDataLoaded(cryptoCurrencyStatus) + onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus) }, ifLeft = { Timber.w(it.toString()) @@ -102,23 +122,19 @@ internal class YieldSupplyModel @Inject constructor( } } - private fun loadTokenStatus(cryptoCurrency: CryptoCurrency.Token) { + private fun loadTokenStatus() { + val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return modelScope.launch(dispatchers.default) { - yieldSupplyGetTokenStatusUseCase(cryptoCurrency) + yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> - val newState = if (tokenStatus.isActive) { - YieldSupplyUM.Initial( - title = resourceReference( - id = R.string.yield_module_token_details_earn_notification_title, - formatArgs = wrappedList(tokenStatus.apy), - ), - onClick = ::onStartEarningClick, - ) - } else { - YieldSupplyUM.Unavailable - } - uiState.update { newState } + uiState.update( + YieldSupplyTokenStatusSuccessTransformer( + tokenStatus = tokenStatus, + onStartEarningClick = ::onStartEarningClick, + ), + ) }.onLeft { + Timber.e(it) uiState.update { YieldSupplyUM.Unavailable } } } @@ -148,38 +164,77 @@ internal class YieldSupplyModel @Inject constructor( .launchIn(modelScope) } - private fun onDataLoaded(cryptoCurrencyStatus: CryptoCurrencyStatus) { + @Suppress("MaximumLineLength") + private fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) { val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus val hasActiveTransaction = cryptoCurrencyStatus.value.hasCurrentNetworkTransactions val yieldTransaction = cryptoCurrencyStatus.value.pendingTransactions.firstOrNull { it.type is TxInfo.TransactionType.YieldSupply }?.type as? TxInfo.TransactionType.YieldSupply + sendInfoAboutProtocolStatus(cryptoCurrencyStatus) - val yieldSupplyUM = when { + when { hasActiveTransaction && yieldTransaction != null -> { coroutineScope.launch(dispatchers.io) { delay(PROCESSING_UPDATE_DELAY) fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id) } - when (yieldTransaction) { - TxInfo.TransactionType.YieldSupply.Enter -> YieldSupplyUM.Processing.Enter - TxInfo.TransactionType.YieldSupply.Exit -> YieldSupplyUM.Processing.Exit + uiState.update { + when (yieldTransaction) { + TxInfo.TransactionType.YieldSupply.Enter -> YieldSupplyUM.Processing.Enter + TxInfo.TransactionType.YieldSupply.Exit -> YieldSupplyUM.Processing.Exit + } } } - yieldSupplyStatus?.isActive == true -> - YieldSupplyUM.Content( - rewardsBalance = TextReference.EMPTY, - rewardsApy = TextReference.EMPTY, - onClick = ::onActiveClick, - isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, - ) - else -> YieldSupplyUM.Loading + yieldSupplyStatus?.isActive == true -> { + val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return + modelScope.launch(dispatchers.default) { + yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) + .onRight { tokenStatus -> + uiState.update { + YieldSupplyUM.Content( + title = resourceReference( + R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, + ), + subtitle = resourceReference( + R.string.yield_module_token_details_earn_notification_earning_on_your_balance_subtitle, + ), + rewardsApy = combinedReference( + resourceReference( + R.string.yield_module_token_details_earn_notification_apy, + ), + stringReference(tokenStatus.apy.toString() + "%"), + ), + onClick = ::onActiveClick, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + ) + } + }.onLeft { + Timber.e(it) + uiState.update { YieldSupplyUM.Loading } + } + } + } + + else -> { + loadTokenStatus() + } } + } - uiState.update { yieldSupplyUM } - - if (yieldSupplyUM is YieldSupplyUM.Loading) { - (cryptoCurrency as? CryptoCurrency.Token)?.let(::loadTokenStatus) + private fun sendInfoAboutProtocolStatus(cryptoCurrencyStatus: CryptoCurrencyStatus) { + if (lastYieldSupplyStatus == cryptoCurrencyStatus.value.yieldSupplyStatus) return + val token = cryptoCurrency as? CryptoCurrency.Token ?: return + modelScope.launch(dispatchers.default) { + if (cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true) { + yieldSupplyActivateUseCase(token).onRight { + lastYieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus + } + } else { + yieldSupplyDeactivateUseCase(token).onRight { + lastYieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus + } + } } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt new file mode 100644 index 0000000000..4fed5db894 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt @@ -0,0 +1,26 @@ +package com.tangem.features.yield.supply.impl.main.model.transformers + +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.utils.transformer.Transformer + +internal class YieldSupplyTokenStatusSuccessTransformer( + private val tokenStatus: YieldMarketToken, + private val onStartEarningClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: YieldSupplyUM): YieldSupplyUM { + if (!tokenStatus.isActive) return YieldSupplyUM.Unavailable + + return YieldSupplyUM.Available( + title = resourceReference( + id = R.string.yield_module_token_details_earn_notification_title, + formatArgs = wrappedList(tokenStatus.apy), + ), + onClick = onStartEarningClick, + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt index df41b8fb28..b596d93729 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt @@ -3,6 +3,7 @@ package com.tangem.features.yield.supply.impl.main.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -17,12 +18,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource 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.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.SpacerW8 import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -34,19 +37,15 @@ import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.utils.StringsSigns @Composable -internal fun YieldSupplyBlockContent( - yieldSupplyUM: YieldSupplyUM, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, -) { +internal fun YieldSupplyBlockContent(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) { AnimatedContent( targetState = yieldSupplyUM, modifier = modifier, ) { supplyUM -> when (supplyUM) { - is YieldSupplyUM.Initial -> SupplyInitial(supplyUM) + is YieldSupplyUM.Available -> SupplyAvailable(supplyUM) YieldSupplyUM.Loading -> SupplyLoading() - is YieldSupplyUM.Content -> SupplyContent(supplyUM, isBalanceHidden) + is YieldSupplyUM.Content -> SupplyContent(supplyUM) YieldSupplyUM.Processing.Enter -> SupplyProcessing( resourceReference(R.string.yield_module_token_details_earn_notification_processing), ) @@ -54,12 +53,13 @@ internal fun YieldSupplyBlockContent( resourceReference(R.string.yield_module_stop_earning), ) YieldSupplyUM.Unavailable -> SupplyUnavailable() + YieldSupplyUM.Initial -> {} } } } @Composable -private fun SupplyInitial(supplyUM: YieldSupplyUM.Initial) { +private fun SupplyAvailable(supplyUM: YieldSupplyUM.Available) { SupplyInfo( title = supplyUM.title, subtitle = resourceReference(R.string.yield_module_token_details_earn_notification_description), @@ -86,7 +86,7 @@ private fun SupplyUnavailable() { } @Composable -private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Boolean) { +private fun SupplyContent(supplyUM: YieldSupplyUM.Content) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -95,22 +95,21 @@ private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Bool .clickable(onClick = supplyUM.onClick) .padding(12.dp), ) { + Image( + painter = painterResource(R.drawable.img_aave_22), + modifier = Modifier.size(36.dp), + contentDescription = null, + ) + SpacerW12() Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f), ) { - Text( - text = stringResourceSafe( - R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, - ), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = supplyUM.rewardsBalance.orMaskWithStars(isBalanceHidden).resolveReference(), + text = supplyUM.title.resolveReference(), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, ) @@ -122,9 +121,15 @@ private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Bool Text( text = supplyUM.rewardsApy.resolveReference(), style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.tertiary, + maxLines = 1, + color = TangemTheme.colors.text.accent, ) } + Text( + text = supplyUM.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) } SpacerW8() AnimatedVisibility(supplyUM.isAllowedToSpend.not()) { @@ -145,36 +150,42 @@ private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Bool @Composable private fun SupplyProcessing(text: TextReference) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(TangemTheme.colors.background.primary) .padding(12.dp), ) { - Text( - text = stringResourceSafe( - R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, - ), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, + Image( + painter = painterResource(R.drawable.img_aave_22), + modifier = Modifier.size(36.dp), + contentDescription = null, ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + SpacerW12() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), ) { Text( - text = text.resolveReference(), - style = TangemTheme.typography.body1, + text = stringResourceSafe( + R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, + ), + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, ) - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = TangemTheme.colors.icon.accent, - strokeWidth = 2.dp, + Text( + text = text.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, ) } + SpacerW8() + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = TangemTheme.colors.icon.accent, + strokeWidth = 2.dp, + ) } } @@ -285,14 +296,14 @@ private fun SupplyInfo( @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::class) params: YieldSupplyUM) { TangemThemePreview { - YieldSupplyBlockContent(params, true) + YieldSupplyBlockContent(yieldSupplyUM = params, modifier = Modifier) } } private class PreviewProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - YieldSupplyUM.Initial( + YieldSupplyUM.Available( title = TextReference.Res( R.string.yield_module_token_details_earn_notification_title, wrappedList("5.1"), @@ -300,13 +311,15 @@ private class PreviewProvider : PreviewParameterProvider { onClick = {}, ), YieldSupplyUM.Content( - rewardsBalance = stringReference("1 USDT"), + title = stringReference("Aave lending is active"), + subtitle = stringReference("Interest accrues automatically"), rewardsApy = stringReference("5.1 % APY"), onClick = {}, isAllowedToSpend = false, ), YieldSupplyUM.Content( - rewardsBalance = stringReference("1 USDT"), + title = stringReference("Aave lending is active"), + subtitle = stringReference("Interest accrues automatically"), rewardsApy = stringReference("5.1 % APY"), onClick = {}, isAllowedToSpend = true, diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt index 4d7abbb513..db0f137890 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt @@ -8,16 +8,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp 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.components.SecondaryButton import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.stringResourceSafe +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.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveModel import com.tangem.features.yield.supply.impl.subcomponents.active.ui.YieldSupplyActiveContent import com.tangem.features.yield.supply.impl.subcomponents.active.ui.YieldSupplyActiveTitle import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.chart.DefaultYieldSupplyChartComponent import kotlinx.coroutines.flow.StateFlow internal class YieldSupplyActiveComponent( @@ -26,6 +29,12 @@ internal class YieldSupplyActiveComponent( ) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: YieldSupplyActiveModel = getOrCreateModel(params = params) + private val chartComponent = DefaultYieldSupplyChartComponent( + appComponentContext = child("chartComponent"), + params = DefaultYieldSupplyChartComponent.Params( + cryptoCurrency = params.cryptoCurrencyStatusFlow.value.currency as CryptoCurrency.Token, + ), + ) @Composable override fun Title() { @@ -37,7 +46,12 @@ internal class YieldSupplyActiveComponent( val state by model.uiState.collectAsStateWithLifecycle() val isBalanceHidden by params.isBalanceHiddenFlow.collectAsStateWithLifecycle() - YieldSupplyActiveContent(state = state, isBalanceHidden = isBalanceHidden, modifier = Modifier) + YieldSupplyActiveContent( + state = state, + isBalanceHidden = isBalanceHidden, + chartComponent = chartComponent, + modifier = Modifier, + ) } @Composable diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt index 7cda12cb63..bf0fdc6e5b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt @@ -10,4 +10,5 @@ internal data class YieldSupplyActiveContentUM( val subtitle: TextReference, val subtitleLink: TextReference, val notificationUM: NotificationUM?, + val apy: TextReference? = null, ) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt index 4b38fbb125..ae85f3f637 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt @@ -5,18 +5,22 @@ 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.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.wrappedList 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.yield.supply.usecase.YieldSupplyGetProtocolBalanceUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveComponent import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSupplyActiveContentUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @ModelScoped @@ -24,6 +28,7 @@ internal class YieldSupplyActiveModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val yieldSupplyGetProtocolBalanceUseCase: YieldSupplyGetProtocolBalanceUseCase, + private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, ) : Model() { private val params: YieldSupplyActiveComponent.Params = paramsContainer.require() @@ -87,6 +92,8 @@ internal class YieldSupplyActiveModel @Inject constructor( null } + loadApy() + uiState.update { it.copy( notificationUM = approvalNotification, @@ -104,6 +111,21 @@ internal class YieldSupplyActiveModel @Inject constructor( .launchIn(modelScope) } + private fun loadApy() { + val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return + modelScope.launch(dispatchers.default) { + yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken).onRight { tokenStatus -> + uiState.update { + it.copy( + apy = TextReference.Str("${tokenStatus.apy}%"), + ) + } + }.onLeft { + Timber.e("Error loading token status") + } + } + } + private companion object { const val AAVEV3_PREFIX = "a" } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt index 0619e6b7a0..eab1197a0e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -15,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.withLink @@ -25,6 +27,7 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.* import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -35,6 +38,7 @@ import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSu internal fun YieldSupplyActiveContent( state: YieldSupplyActiveContentUM, isBalanceHidden: Boolean, + chartComponent: ComposableContentComponent, modifier: Modifier = Modifier, ) { Column( @@ -52,16 +56,8 @@ internal fun YieldSupplyActiveContent( .fillMaxWidth() .padding(12.dp), ) { - Text( - text = stringResourceSafe(R.string.yield_module_earn_sheet_total_earnings_title), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - ResizableText( - text = state.totalEarnings.orMaskWithStars(isBalanceHidden).resolveReference(), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - ) + CurrentApy(state.apy) + chartComponent.Content(Modifier.padding(bottom = 12.dp)) } YieldSupplyActiveMyFunds(state = state, isBalanceHidden = isBalanceHidden) @@ -76,6 +72,46 @@ internal fun YieldSupplyActiveContent( } } +@Composable +private fun CurrentApy(apy: TextReference?, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(vertical = 12.dp)) { + Text( + modifier = Modifier, + text = stringResourceSafe(R.string.yield_module_earn_sheet_current_apy_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + AnimatedContent( + modifier = Modifier.height(32.dp), + targetState = apy?.resolveReference(), + label = "CurrentApy", + ) { apyText -> + if (apyText == null) { + TextShimmer( + modifier = modifier.width(94.dp), + text = "", + style = TangemTheme.typography.head, + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(R.drawable.ic_arrow_up_8), + tint = TangemTheme.colors.text.accent, + contentDescription = null, + modifier = Modifier.padding(end = 6.dp).size(12.dp), + ) + Text( + modifier = modifier, + text = apyText, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.accent, + ) + } + } + } + } +} + @Composable private fun YieldSupplyActiveMyFunds(state: YieldSupplyActiveContentUM, isBalanceHidden: Boolean) { Column( @@ -193,7 +229,11 @@ private fun YieldSupplyActiveBottomSheet_Preview( @PreviewParameter(YieldSupplyActiveBottomSheetPreviewProvider::class) params: YieldSupplyActiveContentUM, ) { TangemThemePreview { - YieldSupplyActiveContent(params, true) + YieldSupplyActiveContent( + state = params, + isBalanceHidden = true, + chartComponent = ComposableContentComponent.EMPTY, + ) } } @@ -210,6 +250,7 @@ private class YieldSupplyActiveBottomSheetPreviewProvider : PreviewParameterProv ), subtitleLink = resourceReference(R.string.common_read_more), notificationUM = NotificationUM.Error.InvalidAmount, + apy = stringReference("5,14%"), ), ) } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index a82ff670c8..4a5568cfe5 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -8,8 +8,10 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.parseToBigDecimal 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.models.wallet.UserWallet import com.tangem.domain.tokens.FetchCurrencyStatusUseCase @@ -17,7 +19,9 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory @@ -55,6 +59,8 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, + private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, + private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() @@ -106,13 +112,26 @@ internal class YieldSupplyStartEarningModel @Inject constructor( } } + private suspend fun getMaxFee(): BigDecimal? { + if (uiState.value.maxFee != BigDecimal.ZERO) return uiState.value.maxFee + val yieldTokenStatus = yieldSupplyGetTokenStatusUseCase(cryptoCurrency as CryptoCurrency.Token) + .getOrNull() + return yieldTokenStatus?.maxFeeNative?.parseToBigDecimal(cryptoCurrency.decimals) + } + private suspend fun onLoadFee() { if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading || uiState.value.isTransactionSending) return + val maxFee = if (uiState.value.maxFee == BigDecimal.ZERO) { + getMaxFee() + } else { + uiState.value.maxFee + } ?: return + val transactionListData = yieldSupplyStartEarningUseCase( userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, - maxNetworkFee = MAX_NETWORK_FEE, + maxNetworkFee = maxFee, ).getOrNull() ?: return uiState.update { @@ -146,7 +165,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( appCurrency = appCurrency, updatedTransactionList = updatedTransactionList, feeValue = feeSum, - maxNetworkFee = MAX_NETWORK_FEE, + maxNetworkFee = maxFee, ), ) yieldSupplyNotificationsUpdateTrigger.triggerUpdate( @@ -189,6 +208,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( }, ifRight = { fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id) + yieldSupplyActivateUseCase(cryptoCurrency) modelScope.launch { params.callback.onTransactionSent() } @@ -274,8 +294,4 @@ internal class YieldSupplyStartEarningModel @Inject constructor( popBack = params.callback::onBackClick, ) } - - private companion object { - val MAX_NETWORK_FEE: BigDecimal = BigDecimal.TEN // TODO replace with value from api - } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index b1bba83d10..ff7bb1fa17 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory @@ -49,6 +50,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( private val urlOpener: UrlOpener, private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, + private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() @@ -134,6 +136,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( ) }, ifRight = { + yieldSupplyDeactivateUseCase(cryptoCurrency) params.callback.onTransactionSent() }, ) diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 34e16dd458..35e4b6fd2f 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -96,7 +96,7 @@ room = "2.6.1" markdown = "0.7.2" markdownComposeView = "0.5.4" usedesk = "4.4.0" -sumsub = "1.37.1" +sumsub = "1.38.0" # endregion Other libraries # region Tools diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt index d1d5a501c6..229c84f88e 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -169,6 +169,9 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "hyperevm/test" -> Blockchain.HyperliquidTestnet "quai-network" -> Blockchain.Quai "quai-network/test" -> Blockchain.QuaiTestnet + "linea" -> Blockchain.Linea + "linea/test" -> Blockchain.LineaTestnet + "arbitrum-nova" -> Blockchain.ArbitrumNova else -> null } } @@ -335,6 +338,9 @@ fun Blockchain.toNetworkId(): String { Blockchain.HyperliquidTestnet -> "hyperevm/test" Blockchain.Quai -> "quai-network" Blockchain.QuaiTestnet -> "quai-network/test" + Blockchain.Linea -> "linea" + Blockchain.LineaTestnet -> "linea/test" + Blockchain.ArbitrumNova -> "arbitrum-nova" } } @@ -440,6 +446,8 @@ fun Blockchain.toCoinId(): String { Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> "pepecoin-network" Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> "hyperliquid" Blockchain.Quai, Blockchain.QuaiTestnet -> "quai-network" + Blockchain.Linea, Blockchain.LineaTestnet -> "linea" + Blockchain.ArbitrumNova -> "arbitrum-nova" } } diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt index 6008e6ea58..1312cb0581 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -3,6 +3,7 @@ package com.tangem.lib.crypto.derivation import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.isUTXO import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.models.network.Network /** * Utility class to recognize the account node in a derivation path based on the blockchain type. @@ -21,12 +22,21 @@ class AccountNodeRecognizer(blockchain: Blockchain) { NON_UTXO_BLOCKCHAIN_NODE_INDEX } + /** Recognizes the account node value from the given [derivationPath] */ + fun recognize(derivationPath: Network.DerivationPath): Long? { + val derivationPathValue = derivationPath.value ?: return null + + return recognize(derivationPathValue = derivationPathValue) + } + /** Recognizes the account node value from the given derivation path string [derivationPathValue] */ fun recognize(derivationPathValue: String): Long? { + if (derivationPathValue.isBlank()) return null + return runCatching { - recognize(derivationPath = DerivationPath(rawPath = derivationPathValue)) - } - .getOrNull() + val cardSdkDerivationPath = DerivationPath(rawPath = derivationPathValue) + recognize(derivationPath = cardSdkDerivationPath) + }.getOrNull() } /** Recognizes the account node value from the given [derivationPath] */ diff --git a/settings.gradle.kts b/settings.gradle.kts index ac0557883f..d69405d8eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -279,6 +279,9 @@ include(":features:tangempay:onboarding:impl") include(":features:create-wallet-selection:api") include(":features:create-wallet-selection:impl") +include(":features:create-wallet-start:api") +include(":features:create-wallet-start:impl") + include(":features:welcome:api") include(":features:welcome:impl")