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..feebdbf578 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() @@ -141,7 +145,7 @@ abstract class BaseTestCase : TestCase( private fun setFeatureToggles() { runBlocking { with(featureTogglesManager as MutableFeatureTogglesManager) { - changeToggle("WALLET_CONNECT_REDESIGN_ENABLED", true) + changeToggle("NEW_TOKEN_RECEIVE_ENABLED", true) changeToggle("WALLET_BALANCE_FETCHER_ENABLED", true) changeToggle("SWAP_REDESIGN_ENABLED", true) changeToggle("NEW_ONRAMP_MAIN_ENABLED", true) 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..1761e307c2 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -1,5 +1,6 @@ package com.tangem.scenarios +import androidx.compose.ui.test.hasText import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType @@ -7,6 +8,7 @@ import com.tangem.screens.* import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockProvider +import com.tangem.utils.StringsSigns.DASH_SIGN import io.qameta.allure.kotlin.Allure.step fun BaseTestCase.scanCard( @@ -65,12 +67,29 @@ fun BaseTestCase.openMainScreen( } } -fun BaseTestCase.synchronizeAddresses(balance: String) { +fun BaseTestCase.synchronizeAddresses( + balance: String? = null, + isBalanceAvailable: Boolean = true +) { step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } - step("Assert wallet balance = '$balance'") { - onMainScreen { totalBalanceText.assertTextContains(balance) } + + when { + !isBalanceAvailable -> step("Assert wallet balance = '$DASH_SIGN'") { + onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } + } + balance != null -> { + step("Assert wallet balance != '$DASH_SIGN'") { + onMainScreen { totalBalanceText.assert(!hasText(DASH_SIGN)) } + } + step("Assert wallet balance = '$balance'") { + onMainScreen { totalBalanceText.assertTextContains(balance) } + } + } + else -> step("Assert wallet balance != '$DASH_SIGN'") { + onMainScreen { totalBalanceText.assert(!hasText(DASH_SIGN)) } + } } } @@ -85,4 +104,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/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/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt index 32772d416d..5821029a67 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -5,6 +5,7 @@ import com.tangem.common.BaseTestCase import com.tangem.core.ui.R import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.NotificationTestTags +import com.tangem.core.ui.test.SendConfirmScreenTestTags import com.tangem.core.ui.test.TopAppBarTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -34,6 +35,11 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + val sendingText: KNode = child { + hasTestTag(SendConfirmScreenTestTags.SENDING_TEXT) + useUnmergedTree = true + } + fun minimumSendAmountErrorIcon(amount: String): KNode = child { hasAnySibling( withText( 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/ThirdPartyAppPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ThirdPartyAppPageObject.kt new file mode 100644 index 0000000000..a294d2464d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ThirdPartyAppPageObject.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 ThirdPartyAppPageObject : 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/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/BlockchainTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt index d1ca4801ee..28b46d345c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt @@ -9,10 +9,6 @@ import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* -import com.tangem.screens.onMainScreen -import com.tangem.screens.onSendAddressScreen -import com.tangem.screens.onSendScreen -import com.tangem.screens.onTokenDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -26,7 +22,6 @@ class BlockchainTest : BaseTestCase() { @Test fun adaCheckMinAmountTest() { val tokenName = "Cardano" - val balance = "$0.00" val errorSendAmount = "0.1" val validSendAmount = "10" val minAmount = "ADA 1.00" @@ -46,7 +41,7 @@ class BlockchainTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } @@ -121,7 +116,6 @@ class BlockchainTest : BaseTestCase() { val tokenName = "XRP Ledger" val amount = "1.00" val currencySymbol = "XRP" - val balance = "$0.00" val userTokensScenarioName = "user_tokens_api" val userTokensScenarioState = "XRP" val rippleAccountInfoScenarioName = "ripple_account_info" @@ -151,7 +145,7 @@ class BlockchainTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt index 1cdfa840e5..da13bb553c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt @@ -1,7 +1,6 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState @@ -22,7 +21,6 @@ class BuyTokenTest : BaseTestCase() { fun errorInProvidersLoadingTest() { val scenarioName = "payment_methods" val tokenTitle = "Bitcoin" - val balance = TOTAL_BALANCE setupHooks( additionalAfterSection = { @@ -39,7 +37,7 @@ class BuyTokenTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -68,7 +66,6 @@ class BuyTokenTest : BaseTestCase() { fun validateCurrencySelectorTest() { setupHooks().run { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE val popularFiatsTitle = "Popular Fiats" val otherCurrenciesTitle = "Other currencies" val australianDollar = "AUD" @@ -84,7 +81,7 @@ class BuyTokenTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -146,7 +143,6 @@ class BuyTokenTest : BaseTestCase() { fun validateBuyTokenScreenTest() { setupHooks().run { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE val euro = "EUR" val fiatAmount = "1" val tokenAmount = "~488.24938338 POL" @@ -160,7 +156,7 @@ class BuyTokenTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -237,7 +233,6 @@ class BuyTokenTest : BaseTestCase() { fun validateResidenceSettingsScreenTest() { setupHooks().run { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE val country = "Albania" val unavailableCountry = "Lebanon" val scenarioName = "payment_methods" @@ -250,7 +245,7 @@ class BuyTokenTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -318,7 +313,6 @@ class BuyTokenTest : BaseTestCase() { fun validateProvidersScreenTest() { setupHooks().run { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE val paymentMethod = "Invoice Revolut Pay" val fiatAmount = "1" val providerNameMercuryo = "Mercuryo" @@ -333,7 +327,7 @@ class BuyTokenTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } @@ -407,7 +401,6 @@ class BuyTokenTest : BaseTestCase() { fun validatePaymentMethodScreenTest() { setupHooks().run { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE val card = "Card" val googlePay = "Google Pay" val invoiceRevolutPay = "Invoice Revolut Pay" @@ -424,7 +417,7 @@ class BuyTokenTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on 'Buy' button") { onMainScreen { buyButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 2264356999..210063dedb 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -3,7 +3,6 @@ package com.tangem.tests import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.RECIPIENT_ADDRESS -import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.clickWithAssertion @@ -28,6 +27,8 @@ class FeedbackTest : BaseTestCase() { @DisplayName("Send feedback: from details") @Test fun sendFeedbackFromDetailsTest() { + val gmailText = "Welcome to Gmail" + setupHooks( additionalAfterSection = { device.uiDevice.pressBack() @@ -42,8 +43,8 @@ class FeedbackTest : BaseTestCase() { step("Click 'Contact support' button") { onDetailsScreen { contactSupportButton.clickWithAssertion() } } - step("Check 'Contact support' intent is called") { - checkSendEMailIntentCalled() + step("Assert 'Gmail' app is open") { + ThirdPartyAppPageObject { assertElementWithTextExists(gmailText) } } } } @@ -52,10 +53,10 @@ class FeedbackTest : BaseTestCase() { @DisplayName("Send feedback: failed transaction") @Test fun sendFeedbackFromFailedTransactionTest() { - val balance = TOTAL_BALANCE val tokenName = "Polygon" val recipientAddress = RECIPIENT_ADDRESS val sendAmount = "1" + val gmailText = "Welcome to Gmail" setupHooks( additionalAfterSection = { @@ -66,7 +67,7 @@ class FeedbackTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } @@ -92,6 +93,9 @@ class FeedbackTest : BaseTestCase() { step("Click 'Next' button") { onSendAddressScreen { nextButton.clickWithAssertion() } } + step("Assert sanding text is displayed") { + onSendConfirmScreen { sendingText.assertIsDisplayed() } + } step("Click 'Send' button") { waitForIdle() onSendConfirmScreen { @@ -100,6 +104,7 @@ class FeedbackTest : BaseTestCase() { } } step("Check 'Failed transaction' dialog") { + waitForIdle() flakySafely(WAIT_UNTIL_TIMEOUT) { checkFailedTransactionDialog() } @@ -107,8 +112,8 @@ class FeedbackTest : BaseTestCase() { step("Click on 'Support' button") { onFailedTransactionDialog { supportButton.performClick() } } - step("Check 'Contact support' intent is called") { - checkSendEMailIntentCalled() + step("Assert 'Gmail' app is open") { + ThirdPartyAppPageObject { assertElementWithTextExists(gmailText) } } } } @@ -117,6 +122,8 @@ class FeedbackTest : BaseTestCase() { @DisplayName("Send feedback: from 'Warning' dialog after card scan") @Test fun sendFeedbackFromScanScreenTest() { + val gmailText = "Welcome to Gmail" + setupHooks( additionalAfterSection = { device.uiDevice.pressBack() @@ -145,8 +152,8 @@ class FeedbackTest : BaseTestCase() { step("Click on 'Request support' button") { ScanWarningDialogPageObject { requestSupportButton.click() } } - step("Check 'Contact support' intent is called") { - checkSendEMailIntentCalled() + step("Assert 'Gmail' app is open") { + ThirdPartyAppPageObject { assertElementWithTextExists(gmailText) } } } } @@ -155,6 +162,8 @@ class FeedbackTest : BaseTestCase() { @DisplayName("Send feedback: from scan already used wallet alert dialog") @Test fun sendFeedbackAfterScanAlreadyUsedWalletTest() { + val gmailText = "Welcome to Gmail" + setupHooks( additionalAfterSection = { device.uiDevice.pressBack() @@ -175,8 +184,8 @@ class FeedbackTest : BaseTestCase() { step("Click on 'Request support' button") { AlreadyUsedWalletDialogPageObject { requestSupportButton.click() } } - step("Check 'Contact support' intent is called") { - checkSendEMailIntentCalled() + step("Assert 'Gmail' app is open") { + ThirdPartyAppPageObject { assertElementWithTextExists(gmailText) } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt index fa4519eede..04d5e8849f 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/HideTokenTest.kt @@ -1,7 +1,6 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.clickWithAssertion import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses @@ -19,13 +18,12 @@ class HideTokenTest : BaseTestCase() { @Test fun hideWalletTokenByHideButtonTest() { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE setupHooks().run { step("Open 'Main Screen'") { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index 76d0d0f0f6..e4ac654ef2 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -2,7 +2,6 @@ package com.tangem.tests import androidx.compose.ui.test.onAllNodesWithText import com.tangem.common.BaseTestCase -import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.swipeVertical @@ -92,13 +91,12 @@ class OrganizeTokensTest : BaseTestCase() { setupHooks().run { val ethereumTitle = "Ethereum" val bitcoinTitle = "Bitcoin" - val balance = TOTAL_BALANCE step("Open 'Main Screen'") { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Check positions of tokens on 'Main Screen'") { onMainScreen { @@ -179,13 +177,12 @@ class OrganizeTokensTest : BaseTestCase() { val bitcoinTitle = "Bitcoin" val polygonTitle = "Polygon" val polExMaticTitle = "POL (ex-MATIC)" - val balance = TOTAL_BALANCE step("Open 'Main Screen'") { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Check positions of tokens on 'Main Screen'") { onMainScreen { 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/SendTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt index b4a7286a5f..9bf7545be1 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt @@ -25,7 +25,6 @@ class SendTest : BaseTestCase() { val currencyName = "POL (ex-MATIC)" val feeCurrencyName = "Ethereum" val feeCurrencySymbol = "ETH" - val balance = "$763.55" val scenarioName = "eth_network_balance" val scenarioState = "Empty" @@ -42,7 +41,7 @@ class SendTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Swipe up") { swipeVertical(SwipeDirection.UP) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index 8bbb02ebab..f91fe7df1d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -1,15 +1,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.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 @@ -23,7 +21,6 @@ class StakingTest : BaseTestCase() { @Test fun validateStakingBlockTest() { val tokenTitle = "POL (ex-MATIC)" - val balance = "$3,299.37" val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Staked" @@ -41,7 +38,7 @@ class StakingTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Assert 'Organize tokens' button is displayed") { onMainScreen { organizeTokensButton().assertIsDisplayed() } @@ -81,7 +78,6 @@ class StakingTest : BaseTestCase() { @Test fun validateStakingMoreScreensTest() { val tokenTitle = "POL (ex-MATIC)" - val balance = "$3,299.37" val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Staked" val stakingAmount = "1" @@ -100,7 +96,7 @@ class StakingTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Assert 'Organize tokens' button is displayed") { onMainScreen { organizeTokensButton().assertIsDisplayed() } @@ -117,116 +113,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() } } } @@ -236,7 +136,6 @@ class StakingTest : BaseTestCase() { @Test fun validateStakingScreensTest() { val tokenTitle = "POL (ex-MATIC)" - val balance = TOTAL_BALANCE val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Started" val stakingAmount = "1" @@ -255,7 +154,7 @@ class StakingTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Assert 'Organize tokens' button is displayed") { onMainScreen { organizeTokensButton().assertIsDisplayed() } @@ -284,107 +183,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/SwapTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt index e30447031f..014b42a242 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt @@ -4,16 +4,15 @@ import androidx.compose.ui.test.hasText import com.tangem.common.BaseTestCase import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnvConfig -import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.* -import com.tangem.common.utils.* +import com.tangem.common.utils.resetWireMockScenarios import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment -import com.tangem.screens.* import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -31,7 +30,6 @@ class SwapTokenTest : BaseTestCase() { fun networkFeeTest() { val inputAmount = "100" val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE setupHooks().run { @@ -40,7 +38,7 @@ class SwapTokenTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -115,13 +113,12 @@ class SwapTokenTest : BaseTestCase() { } ).run { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE step("Open 'Main Screen'") { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } @@ -164,13 +161,12 @@ class SwapTokenTest : BaseTestCase() { val inputAmount = "100" setupHooks().run { val tokenTitle = "Polygon" - val balance = TOTAL_BALANCE step("Open 'Main Screen'") { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Click on token with name: '$tokenTitle'") { onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt index db5e364f20..60dd5885f1 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -1,13 +1,16 @@ 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.screens.onWalletConnectBottomSheet +import com.tangem.screens.onWalletConnectDetailsBottomSheet +import com.tangem.screens.onWalletConnectScanQrScreen +import com.tangem.screens.onWalletConnectScreen +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,8 +24,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() { - val balance = TOTAL_BALANCE + fun openWalletConnectSessionOnMainScreenTest() { val dAppName = "React App" val deepLinkUri = getWcUri() @@ -31,25 +33,34 @@ class WalletConnectTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } step("Create WC session buy deeplink") { 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 +68,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,8 +81,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() { - val balance = TOTAL_BALANCE + fun openWalletConnectSessionNotOnMainScreenTest() { val dAppName = "React App" val deepLinkUri = getWcUri() @@ -80,43 +90,46 @@ class WalletConnectTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } - 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,9 @@ class WalletConnectTest : BaseTestCase() { @DisplayName("WC (React App): open session from deeplink ") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test - fun openWalletConnectSession() { - val balance = TOTAL_BALANCE + fun openWalletConnectSessionTest() { val dAppName = "React App" + val packageName = BuildConfig.APPLICATION_ID val deepLinkUri = getWcUri() setupHooks().run { @@ -135,34 +148,30 @@ class WalletConnectTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(balance) + synchronizeAddresses() } - 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 +179,71 @@ 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 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() + } + 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..8601d27658 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -0,0 +1,394 @@ +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.WAIT_UNTIL_TIMEOUT +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.assertClipboardTextEquals +import com.tangem.common.utils.clearClipboard +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* +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" + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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 bitcoinAddress = BITCOIN_ADDRESS + + setupHooks( + additionalBeforeSection = { + clearClipboard() + }, + additionalAfterSection = { + clearClipboard() + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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") { + waitForIdle() + flakySafely(WAIT_UNTIL_TIMEOUT) { + 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 url = "sell.moonpay.com" + val useWithoutAccount = "Use without an account" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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") { + ThirdPartyAppPageObject { assertChromeIsOpened() } + } + if (ThirdPartyAppPageObject.isElementWithTextExists(useWithoutAccount)) { + step("Click on '$useWithoutAccount' button on Chrome browser") { + ThirdPartyAppPageObject { clickOnElementWithText(useWithoutAccount) } + } + } + step("Assert url contains: '$url'") { + ThirdPartyAppPageObject { assertUrlContains(url) } + } + } + } + + @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" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + 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/TotalBalanceLongTapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt index 05e052bdd4..7eea7bf682 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt @@ -2,7 +2,6 @@ package com.tangem.tests.balance import androidx.compose.ui.test.longClick import com.tangem.common.BaseTestCase -import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.onMainScreen @@ -23,7 +22,7 @@ class TotalBalanceLongTapTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(TOTAL_BALANCE) + synchronizeAddresses() } step("Long tap on total balance block") { onMainScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt index f44bf4ffa4..f02b33f8c2 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUnavailableTest.kt @@ -51,7 +51,7 @@ class TotalBalanceUnavailableTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(DASH_SIGN) + synchronizeAddresses(isBalanceAvailable = false) } step("Assert 'Synchronize addresses' button does not exist") { onMainScreen { @@ -88,7 +88,7 @@ class TotalBalanceUnavailableTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(DASH_SIGN) + synchronizeAddresses(isBalanceAvailable = false) } step("Assert 'Synchronize addresses' button does not exist") { onMainScreen { @@ -125,7 +125,7 @@ class TotalBalanceUnavailableTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(DASH_SIGN) + synchronizeAddresses(isBalanceAvailable = false) } step("Assert 'Synchronize addresses' button does not exist") { onMainScreen { 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..eca72183ce 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -33,7 +33,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(TOTAL_BALANCE) + synchronizeAddresses() } step("Assert $TOTAL_BALANCE is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } @@ -67,7 +67,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(TOTAL_BALANCE) + synchronizeAddresses() } step("Assert $TOTAL_BALANCE is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } @@ -117,7 +117,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(TOTAL_BALANCE) + synchronizeAddresses() } step("Assert $TOTAL_BALANCE is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } @@ -145,7 +145,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(TOTAL_BALANCE) + synchronizeAddresses() } step("Assert $TOTAL_BALANCE is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } @@ -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 { @@ -187,7 +187,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { openMainScreen() } step("Synchronize addresses") { - synchronizeAddresses(TOTAL_BALANCE) + synchronizeAddresses() } step("Assert $TOTAL_BALANCE is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } 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/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 4a0fe276d8..60c8926df6 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -79,6 +79,16 @@ object MarketsDomainModule { ) } + @Provides + @Singleton + fun provideGetTokenMarketCryptoCurrency( + marketsTokenRepository: MarketsTokenRepository, + ): GetTokenMarketCryptoCurrency { + return GetTokenMarketCryptoCurrency( + marketsTokenRepository = marketsTokenRepository, + ) + } + @Provides @Singleton fun provideFilterNetworksUseCase( diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 64dbe9c954..887376f2a3 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -5,6 +5,7 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.TransactionRepository @@ -231,6 +232,18 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideReceiveAddressesFactory( + getEnsNameUseCase: GetEnsNameUseCase, + getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + ): ReceiveAddressesFactory { + return ReceiveAddressesFactory( + getEnsNameUseCase = getEnsNameUseCase, + getViewedTokenReceiveWarningUseCase = getViewedTokenReceiveWarningUseCase, + ) + } + @Provides @Singleton fun provideGetReverseResolvedEnsAddressUseCase( 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 f997c6b4e0..f8af3f022b 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, ) } 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..de38050dcf 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 @@ -3,8 +3,10 @@ package com.tangem.tap.di.domain import com.tangem.domain.blockaid.BlockAidGasEstimate import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository 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 +84,68 @@ 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, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyMinAmountUseCase( + feeRepository: FeeRepository, + quotesRepository: QuotesRepository, + currenciesRepository: CurrenciesRepository, + ): YieldSupplyMinAmountUseCase { + return YieldSupplyMinAmountUseCase( + feeRepository = feeRepository, + quotesRepository = quotesRepository, + currenciesRepository = currenciesRepository, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index 27bc6e7e13..45653eeeea 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -9,7 +9,6 @@ import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.common.deserialization.WalletDataDeserializer import com.tangem.common.extensions.* -import com.tangem.common.map import com.tangem.common.tlv.Tlv import com.tangem.common.tlv.TlvDecoder import com.tangem.crypto.CryptoUtils @@ -145,11 +144,11 @@ internal class ScanProductTask( card = cardDto, session = session, ) { scanResponseResult -> - callback( - scanResponseResult.map { scanResponse -> - scanResponse.copy(visaCardActivationStatus = result.data) - }, - ) + // callback( + // scanResponseResult.map { scanResponse -> + // scanResponse.copy(visaCardActivationStatus = result.data) + // }, + // ) } } is CompletionResult.Failure -> { diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt index ea08934377..2b08bba4a9 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt @@ -10,7 +10,7 @@ internal val UserWallet.sensitiveInformation: UserWalletSensitiveInformation get() = when (this) { is UserWallet.Cold -> UserWalletSensitiveInformation( wallets = scanResponse.card.wallets, - visaCardActivationStatus = scanResponse.visaCardActivationStatus, + // visaCardActivationStatus = scanResponse.visaCardActivationStatus, mobileWallets = null, ) is UserWallet.Hot -> UserWalletSensitiveInformation( @@ -30,7 +30,7 @@ internal val UserWallet.publicInformation: UserWalletPublicInformation card = scanResponse.card.copy( wallets = emptyList(), ), - visaCardActivationStatus = null, + // visaCardActivationStatus = null, ), hasBackupError = hasBackupError, hotWalletId = null, @@ -80,7 +80,7 @@ internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInfo card = scanResponse.card.copy( wallets = requireNotNull(sensitiveInformation.wallets), ), - visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus, + // visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus, ), ) } @@ -113,7 +113,7 @@ internal fun UserWallet.lock(): UserWallet = when (this) { card = scanResponse.card.copy( wallets = emptyList(), ), - visaCardActivationStatus = null, + // visaCardActivationStatus = null, ), ) } diff --git a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt index 931d258b38..2e96094cf1 100644 --- a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt +++ b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt @@ -10,6 +10,7 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.error.ext.tangemError import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.domain.card.common.visa.VisaWalletPublicKeyUtility +import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaCardScanError diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt index ec4e9e12ab..521d22b51a 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt @@ -4,7 +4,6 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.twinsIsTwinned import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.proxy.redux.DaggerGraphState @@ -20,9 +19,10 @@ object OnboardingHelper { return when { response.cardTypesResolver.isVisaWallet() -> { - if (response.visaCardActivationStatus == null) error("Visa card activation status is null") - - response.visaCardActivationStatus !is VisaCardActivationStatus.Activated + // if (response.visaCardActivationStatus == null) error("Visa card activation status is null") + // + // response.visaCardActivationStatus !is VisaCardActivationStatus.Activated + return true } response.cardTypesResolver.isTangemTwins() -> { diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt index be2955e491..6797731a84 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt @@ -110,6 +110,7 @@ internal class WelcomeMiddleware { batch = scanResponse.card.batchId, signInType = signInType, walletsCount = userWalletsListManager.walletsCount.toString(), + isImported = userWallet.isImported, hasBackup = scanResponse.card.backupStatus?.isActive, ), ) 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..3713ecba7b 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, @@ -593,10 +616,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.TangemPayDetails -> { createComponentChild( context = context, - params = TangemPayDetailsComponent.Params( - customerWalletAddress = route.customerWalletAddress, - cardNumberEnd = route.cardNumberEnd, - ), + params = TangemPayDetailsComponent.Params(config = route.config), componentFactory = tangemPayDetailsComponentFactory, ) } diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index 17e65d1d6a..9fe3f2a5e4 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(projects.domain.appCurrency.models) implementation(projects.domain.nft.models) implementation(projects.domain.feedback.models) + implementation(projects.domain.visa.models) /* Libs - Other */ api(deps.kotlin.serialization) 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..74f9b32244 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 @@ -18,6 +18,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.onramp.model.OnrampSource +import com.tangem.domain.pay.TangemPayDetailsConfig import kotlinx.serialization.Serializable @SuppressLint("UnsafeOptInUsageError") @@ -51,50 +52,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 +123,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 +175,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 +238,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 +312,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") @@ -442,8 +389,7 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class TangemPayDetails( - val customerWalletAddress: String, - val cardNumberEnd: String, + val config: TangemPayDetailsConfig, ) : AppRoute(path = "/tangem_pay_details") @Serializable 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..e270e01789 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 @@ -19,6 +19,7 @@ class AccountPortfolioItemUMConverter( private val appCurrency: AppCurrency? = null, private val accountBalance: TotalFiatBalance? = null, private val isBalanceHidden: Boolean = false, + private val isEnabled: Boolean = true, private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None, ) : Converter { @@ -29,11 +30,10 @@ class AccountPortfolioItemUMConverter( name = value.accountName.toUM().value, information = getInfo(value), balance = getBalanceInfo(), - isEnabled = true, + isEnabled = isEnabled, endIcon = endIcon, onClick = { onClick(value.accountId) }, imageState = getImageState(value), - label = null, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt new file mode 100644 index 0000000000..e2d1c5b4c1 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt @@ -0,0 +1,60 @@ +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.platform.testTag +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.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.SendScreenTestTags + +/** + * A composable function that displays an account label (icon + name) with an optional prefix. + * + * Depending on the type of [accountTitleUM], it either shows a prefix text followed by + * an account label (with name and icon) or just a title text. + * + * @param accountTitleUM The data model containing information about the account title. + * @param modifier Optional [Modifier] for styling. + * @param textStyle The [TextStyle] to apply to the text elements. Defaults to subtitle2 style from TangemTheme. + */ +@Composable +fun AccountTitle( + accountTitleUM: AccountTitleUM, + modifier: Modifier = Modifier, + textStyle: TextStyle = TangemTheme.typography.subtitle2, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + when (accountTitleUM) { + is AccountTitleUM.Account -> { + Text( + text = accountTitleUM.prefixText.resolveReference(), + style = textStyle, + color = TangemTheme.colors.text.tertiary, + ) + AccountLabel( + name = accountTitleUM.name, + icon = accountTitleUM.icon, + iconSize = AccountIconSize.ExtraSmall, + nameStyle = textStyle, + ) + } + is AccountTitleUM.Text -> Text( + text = accountTitleUM.title.resolveReference(), + style = textStyle, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), + ) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitleUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitleUM.kt new file mode 100644 index 0000000000..5b9537de75 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitleUM.kt @@ -0,0 +1,24 @@ +package com.tangem.common.ui.account + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +/** + * A sealed interface representing the title of an account, which can be either a simple text + * or a more complex account representation with a prefix, name, and icon. + */ +@Immutable +sealed interface AccountTitleUM { + + /** Represents a simple text title. */ + data class Text( + val title: TextReference, + ) : AccountTitleUM + + /** Represents an account with a prefix, name, and icon. */ + data class Account( + val prefixText: TextReference, + val name: TextReference, + val icon: CryptoPortfolioIconUM, + ) : AccountTitleUM +} \ No newline at end of file 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/AmountAccountConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountAccountConverter.kt new file mode 100644 index 0000000000..b859d383d1 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountAccountConverter.kt @@ -0,0 +1,27 @@ +package com.tangem.common.ui.amountScreen.converters + +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.account.Account +import com.tangem.utils.converter.Converter + +class AmountAccountConverter( + private val prefixText: TextReference, + private val isAccountsMode: Boolean, + private val walletTitle: TextReference, +) : Converter { + override fun convert(value: Account.CryptoPortfolio?): AccountTitleUM { + return if (value != null && isAccountsMode) { + AccountTitleUM.Account( + name = value.accountName.toUM().value, + icon = value.icon.toUM(), + prefixText = prefixText, + ) + } else { + AccountTitleUM.Text( + title = walletTitle, + ) + } + } +} \ No newline at end of file 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..c73dd77911 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,114 +1,47 @@ package com.tangem.common.ui.amountScreen.converters -import com.tangem.common.ui.R +import com.tangem.common.ui.account.AccountTitleUM 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, private val maxEnterAmount: EnterAmountBoundary, private val iconStateConverter: CryptoCurrencyToIconStateConverter, private val isBalanceHidden: Boolean, + private val accountTitleUM: AccountTitleUM, ) : Converter { private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { - AmountFieldConverterV2( + AmountFieldConverter( clickIntents = clickIntents, cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrency = appCurrency, @@ -118,19 +51,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), + accountTitleUM = accountTitleUM, availableBalanceCrypto = stringReference(crypto).orMaskWithStars(isBalanceHidden), availableBalanceFiat = if (isBalanceHidden) { TextReference.EMPTY @@ -145,25 +72,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..09e6c0d211 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 @@ -1,10 +1,10 @@ package com.tangem.common.ui.amountScreen.models import androidx.compose.runtime.Stable +import com.tangem.common.ui.account.AccountTitleUM 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 +12,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 accountTitleUM info about current account or wallet * @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 +27,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 accountTitleUM: AccountTitleUM, 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 +39,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..d5eafdf94d 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 @@ -2,41 +2,33 @@ package com.tangem.common.ui.amountScreen.preview import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import com.tangem.common.ui.R +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM 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 +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.CryptoPortfolioIcon 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 \$)"), + accountTitleUM = AccountTitleUM.Text(stringReference("Family Wallet")), 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 +57,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 +73,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( @@ -101,6 +84,17 @@ object AmountStatePreviewData { ), ), ) + + val amountStateV2Accounts = amountState.copy( + accountTitleUM = AccountTitleUM.Account( + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(), + prefixText = resourceReference(R.string.common_from), + ), + availableBalanceCrypto = stringReference("2 130,81231238 USDT"), + availableBalanceFiat = stringReference(" ${StringsSigns.DOT} 1 232 129,12 $"), + ) + val amountErrorState = amountWithValueState.copy( amountTextField = amountWithValueState.amountTextField.copy( isError = true, 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..137328bcf0 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.account.AccountTitle 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.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,12 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) .padding(TangemTheme.dimens.spacing16), ) { - CurrencyIcon(state = amountState.tokenIconState) + AccountTitle(accountTitleUM = amountState.accountTitleUM) + SpacerH(20.dp) + CurrencyIcon( + state = amountState.tokenIconState, + iconSize = 40.dp, + ) ResizableText( text = firstAmount, style = TangemTheme.typography.h2, @@ -68,8 +74,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 +83,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..3ffd401d66 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,10 +9,13 @@ 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 import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData import com.tangem.core.ui.components.ResizableText @@ -28,6 +31,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( @@ -63,7 +67,7 @@ fun AmountBlockV2( val currencyTitle = amount.cryptoAmount.currencySymbol AmountBlockV2( - title = amountState.title, + accountTitleUM = amountState.accountTitleUM, balance = amountState.availableBalanceCrypto, currencyTitle = currencyTitle, currencyIconState = amountState.tokenIconState, @@ -80,7 +84,7 @@ fun AmountBlockV2( @Suppress("LongParameterList", "LongMethod") @Composable private fun AmountBlockV2( - title: TextReference, + accountTitleUM: AccountTitleUM, balance: TextReference, currencyTitle: String, currencyIconState: CurrencyIconState, @@ -105,11 +109,7 @@ private fun AmountBlockV2( .padding(TangemTheme.dimens.spacing16), ) { Row { - Text( - text = title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) + AccountTitle(accountTitleUM) SpacerWMax() Text( text = balance.resolveReference(), @@ -133,6 +133,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 +143,7 @@ private fun AmountBlockV2( style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, maxLines = 1, + modifier = Modifier.testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), ) extraContent() } @@ -184,6 +186,7 @@ private class AmountBlockV2PreviewProvider : PreviewParameterProvider get() = sequenceOf( AmountStatePreviewData.amountState, + AmountStatePreviewData.amountStateV2Accounts, ) } // endregion \ No newline at end of file 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..db265212a0 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,16 +16,15 @@ 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.account.AccountTitle import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.TextShimmer 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 +32,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, @@ -114,11 +59,7 @@ internal fun LazyListScope.amountFieldV2( modifier = Modifier.width(60.dp), ) } else { - Text( - text = amountState.title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) + AccountTitle(amountState.accountTitleUM) } AmountFieldV2( amountUM = amountState, @@ -175,7 +116,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 +125,52 @@ 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 -> { + Text( + text = currentAmount.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 = currentAmount.availableBalanceCrypto.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd( + currentAmount.amountTextField.cryptoAmount.currencySymbol.length, + ), + modifier = Modifier + .weight(1f, fill = false) + .testTag(SendScreenTestTags.PRIMARY_AMOUNT), + ) + EllipsisText( + text = currentAmount.availableBalanceFiat.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd( + currentAmount.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/footers/SendingText.kt b/common/ui/src/main/java/com/tangem/common/ui/footers/SendingText.kt index ba14042e47..16f3c89509 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/footers/SendingText.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/footers/SendingText.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.Keyboard @@ -17,6 +18,7 @@ import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.SendConfirmScreenTestTags /** * Sending info text with display animation. @@ -52,7 +54,8 @@ fun SendingText(footerText: TextReference, modifier: Modifier = Modifier) { color = TangemTheme.colors.text.tertiary, modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .testTag(SendConfirmScreenTestTags.SENDING_TEXT), ) } } \ No newline at end of file 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..5f431fee2f 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 @@ -60,40 +57,53 @@ fun UserWalletItem( onClick = state.onClick, enabled = state.isEnabled, ) { - Row( + UserWalletItemRow( + state = state, modifier = Modifier .fillMaxWidth() .heightIn(min = TangemTheme.dimens.size68) .padding(all = TangemTheme.dimens.spacing12), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), - ) { - CardImage(state.imageState) - NameAndInfo( - modifier = Modifier.weight(1f), - name = state.name, - information = state.information, - balance = state.balance, - ) + ) + } +} - state.label?.let { Label(it) } +@Composable +fun UserWalletItemRow(state: UserWalletItemUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + CardImage(state.imageState) + NameAndInfo( + modifier = Modifier.weight(1f), + name = state.name, + information = state.information, + balance = state.balance, + ) - when (state.endIcon) { - UserWalletItemUM.EndIcon.None -> Unit - UserWalletItemUM.EndIcon.Arrow -> { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_right_24), - tint = TangemTheme.colors.icon.informative, - contentDescription = null, - ) - } - UserWalletItemUM.EndIcon.Checkmark -> { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_check_24), - tint = TangemTheme.colors.icon.accent, - contentDescription = null, - ) - } + when (state.endIcon) { + UserWalletItemUM.EndIcon.None -> Unit + UserWalletItemUM.EndIcon.Arrow -> { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_right_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + UserWalletItemUM.EndIcon.Checkmark -> { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + UserWalletItemUM.EndIcon.Warning -> { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_alert_circle_24), + tint = TangemTheme.colors.icon.warning, + contentDescription = null, + ) } } } @@ -316,10 +326,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/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt index 875cf24182..9d2974a077 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt @@ -19,12 +19,14 @@ sealed class Basic( batch: String, signInType: SignInType, walletsCount: String, + isImported: Boolean, hasBackup: Boolean?, ) : Basic( event = "Signed in", params = buildMap { put(AnalyticsParam.CURRENCY, currency.value) put(AnalyticsParam.BATCH, batch) + put("Wallet Type", if (isImported) "Seed Phrase" else "Seedless") put("Sign in type", signInType.name) put("Wallets Count", walletsCount) if (hasBackup != null) { 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/build.gradle.kts b/core/datasource/build.gradle.kts index 88c906000b..2633b0dc89 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(projects.domain.nft.models) implementation(projects.domain.walletConnect.models) implementation(projects.domain.yieldSupply.models) + implementation(projects.domain.visa.models) /** Tangem libraries */ implementation(tangemDeps.blockchain) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt index c6e72a8a75..755887fd7f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt @@ -16,6 +16,9 @@ enum class ApiEnvironment { @Json(name = "DEV_2") DEV_2, + @Json(name = "DEV_3") + DEV_3, + @Json(name = "STAGE") STAGE, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt index 23b46cf40b..6d4b9a1e5a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -28,6 +28,7 @@ internal class Express( override val environmentConfigs: List = listOf( createDevEnvironment(), createDev2Environment(), + createDev3Environment(), createStageEnvironment(), createMockedEnvironment(), createProdEnvironment(), @@ -60,6 +61,12 @@ internal class Express( headers = createHeaders(isProd = false), ) + private fun createDev3Environment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV_3, + baseUrl = "[REDACTED_ENV_URL]", + headers = createHeaders(isProd = false), + ) + private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.STAGE, baseUrl = "[REDACTED_ENV_URL]", 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/common/config/TangemTech.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt index 4a8e1db349..8d44291374 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt @@ -73,6 +73,7 @@ internal class TangemTech( ApiEnvironment.MOCK, ApiEnvironment.DEV, ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, -> environmentConfigStorage.getConfigSync().tangemApiKeyDev ApiEnvironment.STAGE -> environmentConfigStorage.getConfigSync().tangemApiKeyStage ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().tangemApiKey diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 24b58f4b95..65a28d997e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -17,6 +17,7 @@ data class CustomerMeResponse( @Json(name = "product_instance") val productInstance: ProductInstance?, @Json(name = "payment_account") val paymentAccount: PaymentAccount?, @Json(name = "kyc") val kyc: Kyc?, + @Json(name = "depositAddress") val depositAddress: String?, @Json(name = "card") val card: Card?, @Json(name = "balance") val balance: Balance?, ) 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..c1e4a4f7dc 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,9 +9,8 @@ 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 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -28,6 +27,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") @@ -38,8 +38,8 @@ class MoshiModule { .add(BigIntegerAdapter()) .add(LocalDateAdapter()) .add(DateTimeAdapter()) - .add(VisaActivationRemoteState.jsonAdapter) - .add(VisaCardActivationStatus.jsonAdapter) + // .add(VisaActivationRemoteState.jsonAdapter) + // .add(VisaCardActivationStatus.jsonAdapter) .add( NamePolymorphicAdapterFactory.of(NetworkStatusDM::class.java) .withSubtype(NetworkStatusDM.Verified::class.java, "amounts") @@ -84,8 +84,8 @@ class MoshiModule { val typedAdapters = MoshiJsonConverter.getTangemSdkTypedAdapters() return Moshi.Builder().apply { - add(VisaActivationRemoteState.jsonAdapter) - add(VisaCardActivationStatus.jsonAdapter) + // add(VisaActivationRemoteState.jsonAdapter) + // add(VisaCardActivationStatus.jsonAdapter) adapters.forEach { this.add(it) } typedAdapters.forEach { add(it.key, it.value) } addLast(KotlinJsonAdapterFactory()) 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/network/entity/NetworkStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt index 48a30365ee..37dfea2ebb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt @@ -9,7 +9,7 @@ import java.math.BigDecimal /** * Network status for storage in the local cache. Supports two types - the [Verified] and [NoAccount]. * - * @see [com.tangem.domain.tokens.model.NetworkStatus] + * @see [com.tangem.domain.models.network.NetworkStatus] */ @JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER) sealed interface NetworkStatusDM { @@ -41,8 +41,8 @@ sealed interface NetworkStatusDM { @Json(name = "derivation_path") override val derivationPath: DerivationPath, @Json(name = "selected_address") override val selectedAddress: String, @Json(name = "available_addresses") override val availableAddresses: Set
, - @Json(name = "amounts") val amounts: Map, - @Json(name = "yield_supply_statuses") val yieldSupplyStatuses: Map = emptyMap(), + @Json(name = "amounts") val amounts: List, + @Json(name = "yield_supply_statuses") val yieldSupplyStatuses: List, ) : NetworkStatusDM /** @@ -107,10 +107,44 @@ sealed interface NetworkStatusDM { } } + @JsonClass(generateAdapter = true) + data class CurrencyAmount( + @Json(name = "id") val id: CurrencyId, + @Json(name = "amount") val amount: BigDecimal, + ) + @JsonClass(generateAdapter = true) data class YieldSupplyStatus( + @Json(name = "id") val id: CurrencyId, @Json(name = "is_active") val isActive: Boolean, @Json(name = "is_initialized") val isInitialized: Boolean, @Json(name = "is_allowed_to_spend") val isAllowedToSpend: Boolean, ) + + @JsonClass(generateAdapter = true) + data class CurrencyId( + @Json(name = "value") val value: String, + ) { + + companion object Companion { + + const val CONTRACT_ADDRESS_DELIMITER = '\u2693' // ⚓ + + fun createCoinId(coinId: String): CurrencyId { + return CurrencyId(value = coinId) + } + + fun createTokenId(rawTokenId: String?, contractAddress: String): CurrencyId { + return CurrencyId( + value = buildString { + if (rawTokenId != null) { + append(rawTokenId) + } + append(CONTRACT_ADDRESS_DELIMITER) + append(contractAddress) + }, + ) + } + } + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt index 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/local/network/entity/NetworkStatusDMSerializationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt index 4bc735b0dc..4ddef20c2c 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/network/entity/NetworkStatusDMSerializationTest.kt @@ -64,9 +64,15 @@ class NetworkStatusDMSerializationTest { NetworkStatusDM.Address("0x123456", NetworkStatusDM.Address.Type.Primary), NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary), ), - amounts = mapOf("ETH" to BigDecimal("1.2345")), - yieldSupplyStatuses = mapOf( - "ETH" to NetworkStatusDM.YieldSupplyStatus( + amounts = listOf( + NetworkStatusDM.CurrencyAmount( + id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"), + amount = BigDecimal("1.2345"), + ), + ), + yieldSupplyStatuses = listOf( + NetworkStatusDM.YieldSupplyStatus( + id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"), isActive = false, isInitialized = false, isAllowedToSpend = false, @@ -91,9 +97,15 @@ class NetworkStatusDMSerializationTest { NetworkStatusDM.Address("0x123456", NetworkStatusDM.Address.Type.Primary), NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary), ), - amounts = mapOf("ETH" to BigDecimal("1.2345")), - yieldSupplyStatuses = mapOf( - "ETH" to NetworkStatusDM.YieldSupplyStatus( + amounts = listOf( + NetworkStatusDM.CurrencyAmount( + id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"), + amount = BigDecimal("1.2345"), + ), + ), + yieldSupplyStatuses = listOf( + NetworkStatusDM.YieldSupplyStatus( + id = NetworkStatusDM.CurrencyId.createCoinId("ethereum"), isActive = false, isInitialized = false, isAllowedToSpend = false, 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/SendConfirmScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendConfirmScreenTestTags.kt new file mode 100644 index 0000000000..2e3212c540 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendConfirmScreenTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object SendConfirmScreenTestTags { + const val SENDING_TEXT = "SEND_CONFIRM_SCREEN_SENDING_TEXT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt index 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/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt b/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt index 576a078921..4b6406002d 100644 --- a/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt +++ b/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt @@ -20,4 +20,6 @@ object TangemBlogUrlBuilder { } const val RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP = "https://tangem.com/en/blog/post/give-revoke-permission/" + + const val YIELD_SUPPLY_HOW_IT_WORKS_URL = "https://tangem.com/en/blog/post/savings-account" } \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/converter/Converter.kt b/core/utils/src/main/java/com/tangem/utils/converter/Converter.kt index 603ebf1fcd..7606fe5476 100644 --- a/core/utils/src/main/java/com/tangem/utils/converter/Converter.kt +++ b/core/utils/src/main/java/com/tangem/utils/converter/Converter.kt @@ -1,6 +1,6 @@ package com.tangem.utils.converter -interface Converter { +interface Converter { fun convert(value: I): O diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index d5c297866a..a818b24ccb 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -24,7 +24,9 @@ dependencies { // region Project - Domain api(projects.domain.account) api(projects.domain.card) + api(projects.domain.common) api(projects.domain.models) + api(projects.domain.tokens) // endregion // region Project - Data 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/CryptoPortfolioConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt index 0117b2b653..fd06cb7bba 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt @@ -50,7 +50,9 @@ internal class CryptoPortfolioConverter @AssistedInject constructor( derivationIndex = value.derivationIndex.value, icon = value.icon.value.name, iconColor = value.icon.color.name, - tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken), + tokens = value.cryptoCurrencies.map { + userTokensResponseFactory.createResponseToken(currency = it, accountId = value.accountId) + }, ) } 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/AccountListCryptoCurrenciesProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/AccountListCryptoCurrenciesProducer.kt new file mode 100644 index 0000000000..7217e10a20 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/AccountListCryptoCurrenciesProducer.kt @@ -0,0 +1,64 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.some +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.* + +/** + * Implementation of [MultiWalletCryptoCurrenciesProducer] that produces crypto currencies of all accounts + * + * @property params params + * @property userWalletsStore UserWallet's store + * @property accountsResponseStoreFactory factory to create store with accounts response + * @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse` + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor( + @Assisted val params: MultiWalletCryptoCurrenciesProducer.Params, + private val userWalletsStore: UserWalletsStore, + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiWalletCryptoCurrenciesProducer { + + override val fallback: Option> = emptySet().some() + + override fun produce(): Flow> { + val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId) + + if (!userWallet.isMultiCurrency) { + error("${this::class.simpleName} supports only multi-currency wallet") + } + + return accountsResponseStoreFactory.create(userWalletId = userWallet.walletId).data + .distinctUntilChanged() + .map { response -> + if (response == null) return@map emptySet() + + responseCryptoCurrenciesFactory.createCurrencies( + response = response.toUserTokensResponse(), + userWallet = userWallet, + ).toSet() + } + .onEmpty { emit(emptySet()) } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiWalletCryptoCurrenciesProducer.Factory { + override fun create(params: MultiWalletCryptoCurrenciesProducer.Params): AccountListCryptoCurrenciesProducer + } +} \ 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/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducer.kt similarity index 98% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt rename to data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducer.kt index dc71df52b3..8c46088747 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducer.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokens +package com.tangem.data.account.producer import arrow.core.Option import arrow.core.some 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..5cbc0ba8e0 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -9,8 +9,10 @@ import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStore import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse @@ -24,9 +26,11 @@ 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 +import timber.log.Timber /** [REDACTED_AUTHOR] @@ -38,6 +42,7 @@ internal class DefaultAccountsCRUDRepository( private val accountsResponseStoreFactory: AccountsResponseStoreFactory, private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory, private val userWalletsStore: UserWalletsStore, + private val userTokensSaver: UserTokensSaver, private val eTagsStore: ETagsStore, private val convertersContainer: AccountConverterFactoryContainer, private val dispatchers: CoroutineDispatcherProvider, @@ -101,12 +106,44 @@ 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 syncTokens(userWalletId: UserWalletId) { + val response = getAccountsResponseSync(userWalletId = userWalletId) + + if (response == null) { + Timber.e("Can't sync tokens. No accounts response found for wallet: $userWalletId") + return + } + + userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) } 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/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt similarity index 99% rename from data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducerTest.kt rename to data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt index df9fa14413..bbdc8679ef 100644 --- a/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiWalletCryptoCurrenciesProducerTest.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokens +package com.tangem.data.account.producer import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain 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..5881a3194e 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 @@ -11,6 +11,7 @@ import com.tangem.data.account.store.ArchivedAccountsStore import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse @@ -25,7 +26,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 +37,7 @@ import kotlin.time.Duration.Companion.minutes /** [REDACTED_AUTHOR] */ +@Suppress("UnusedFlow") @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultAccountsCRUDRepositoryTest { @@ -52,6 +53,7 @@ class DefaultAccountsCRUDRepositoryTest { private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore) private val userWalletsStore: UserWalletsStore = mockk() + private val userTokensSaver: UserTokensSaver = mockk() private val eTagsStore: ETagsStore = mockk() private val convertersContainer: AccountConverterFactoryContainer = mockk() @@ -64,6 +66,7 @@ class DefaultAccountsCRUDRepositoryTest { accountsResponseStoreFactory = accountsResponseStoreFactory, archivedAccountsStoreFactory = archivedAccountsStoreFactory, userWalletsStore = userWalletsStore, + userTokensSaver = userTokensSaver, eTagsStore = eTagsStore, convertersContainer = convertersContainer, dispatchers = TestingCoroutineDispatcherProvider(), @@ -580,22 +583,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 +603,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 +636,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/currency/CryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt index 36e2001891..311c07652a 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt @@ -1,5 +1,6 @@ package com.tangem.data.common.currency +import com.tangem.blockchain.blockchains.ethereum.Chain import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.fromNetworkId @@ -73,6 +74,17 @@ class CryptoCurrencyFactory( ) } + fun createCoin(chainId: Int, extraDerivationPath: String?, userWallet: UserWallet): CryptoCurrency.Coin? { + val blockchain: Blockchain? = Chain.entries.find { it.id == chainId }?.blockchain + + return if (blockchain != null) { + createCoin(blockchain, extraDerivationPath, userWallet) + } else { + Timber.e("Unable to get blockchain from chainId == $chainId") + null + } + } + fun createCoin( blockchain: Blockchain, extraDerivationPath: String?, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/TokensOperations.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/TokensOperations.kt index b98786855f..7989830074 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/TokensOperations.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/TokensOperations.kt @@ -81,7 +81,10 @@ private fun getCurrencyIdBody(network: Network): CurrencyIdBody { rawId = network.rawId, derivationPath = path.value, ) - is Network.DerivationPath.Card, + is Network.DerivationPath.Card -> CurrencyIdBody.NetworkIdWithDerivationPath( + rawId = network.rawId, + derivationPath = path.value, + ) is Network.DerivationPath.None, -> CurrencyIdBody.NetworkId(network.rawId) } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index 84b75233ac..c36aa9fdbf 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -1,18 +1,21 @@ package com.tangem.data.common.currency import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import javax.inject.Inject +// TODO: [REDACTED_JIRA] class UserTokensResponseFactory @Inject constructor() { fun createUserTokensResponse( currencies: List, isGroupedByNetwork: Boolean, isSortedByBalance: Boolean, + accountId: AccountId? = null, ): UserTokensResponse { return UserTokensResponse( - tokens = currencies.map(::createResponseToken), + tokens = currencies.map { createResponseToken(currency = it, accountId = accountId) }, group = if (isGroupedByNetwork) { UserTokensResponse.GroupType.NETWORK } else { @@ -26,10 +29,11 @@ class UserTokensResponseFactory @Inject constructor() { ) } - fun createResponseToken(currency: CryptoCurrency): UserTokensResponse.Token { + fun createResponseToken(currency: CryptoCurrency, accountId: AccountId? = null): UserTokensResponse.Token { return with(currency) { UserTokensResponse.Token( id = id.rawCurrencyId?.value, + accountId = accountId?.value, networkId = network.backendId, derivationPath = network.derivationPath.value, name = name, 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/build.gradle.kts b/data/networks/build.gradle.kts index d5f03f3e85..4653fb36ff 100644 --- a/data/networks/build.gradle.kts +++ b/data/networks/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { // region Project - Libs implementation(projects.libs.blockchainSdk) + implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } // endregion // region DI diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt new file mode 100644 index 0000000000..728b1db741 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt @@ -0,0 +1,98 @@ +package com.tangem.data.networks.converters + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId +import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId.Companion.CONTRACT_ADDRESS_DELIMITER +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.utils.converter.TwoWayConverter +import com.tangem.domain.models.currency.CryptoCurrency.ID.Suffix as CurrencyIdSuffix + +/** + * Converts between [CurrencyId] and [CryptoCurrency.ID]. + * + * @property rawNetworkId the raw network ID associated with the currency + * @property derivationPath the derivation path used for the network + * +[REDACTED_AUTHOR] + */ +internal class CurrencyIdConverter( + private val rawNetworkId: String, + private val derivationPath: Network.DerivationPath, +) : TwoWayConverter { + + override fun convert(value: CurrencyId): CryptoCurrency.ID { + val suffixParts = value.value.split(CONTRACT_ADDRESS_DELIMITER) + + val rawId = suffixParts.getOrNull(0) + val contractAddress = suffixParts.getOrNull(1) + + return if (contractAddress.isNullOrBlank()) { + getCoinId( + coinId = rawId.takeUnless { it.isNullOrBlank() } + ?: error("Coin id is null for $rawNetworkId with $derivationPath"), + ) + } else { + getTokenId( + rawTokenId = rawId?.ifBlank { null }, + contractAddress = contractAddress, + ) + } + } + + override fun convertBack(value: CryptoCurrency.ID): CurrencyId { + return if (value.isCoin) { + CurrencyId.createCoinId( + coinId = Blockchain.fromId(value.rawNetworkId).toCoinId(), + ) + } else { + CurrencyId.createTokenId( + rawTokenId = value.rawCurrencyId?.value, + contractAddress = requireNotNull(value.contractAddress) { + "Token contractAddress is null for token id: $this" + }, + ) + } + } + + private fun getCoinId(coinId: String): CryptoCurrency.ID { + return CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = getCurrencyIdBody(), + suffix = CurrencyIdSuffix.RawID(rawId = coinId), + ) + } + + private fun getTokenId(rawTokenId: String?, contractAddress: String): CryptoCurrency.ID { + val suffix = if (rawTokenId == null) { + CurrencyIdSuffix.ContractAddress(contractAddress) + } else { + CurrencyIdSuffix.RawID(rawTokenId, contractAddress) + } + + return CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = getCurrencyIdBody(), + suffix = suffix, + ) + } + + private fun getCurrencyIdBody(): CryptoCurrency.ID.Body { + return when (derivationPath) { + is Network.DerivationPath.Card -> { + CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = rawNetworkId, + derivationPath = derivationPath.value, + ) + } + is Network.DerivationPath.Custom -> { + CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = rawNetworkId, + derivationPath = derivationPath.value, + ) + } + is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(rawNetworkId) + } + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt index 79c210eaac..a2876c0071 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt @@ -1,35 +1,46 @@ package com.tangem.data.networks.converters +import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyAmount import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkStatus import com.tangem.utils.converter.TwoWayConverter -import com.tangem.utils.extensions.mapNotNullValues -import java.math.BigDecimal -private typealias AmountsDataModel = Map +private typealias AmountsDataModel = List private typealias AmountsDomainModel = Map /** * Converter from [AmountsDataModel] to [AmountsDomainModel] and vice versa * + * @param rawNetworkId the raw network ID associated with the currency + * @param derivationPath the derivation path used for the network + * [REDACTED_AUTHOR] */ -internal object NetworkAmountsConverter : TwoWayConverter { +internal class NetworkAmountsConverter( + rawNetworkId: String, + derivationPath: Network.DerivationPath, +) : TwoWayConverter { + + private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath) override fun convert(value: AmountsDataModel): AmountsDomainModel { - return value - .mapKeys { CryptoCurrency.ID.fromValue(value = it.key) } - .mapValues { (_, amount) -> NetworkStatus.Amount.Loaded(value = amount) } + return value.associate { + val currencyId = currencyIdConverter.convert(value = it.id) + val amount = NetworkStatus.Amount.Loaded(value = it.amount) + + currencyId to amount + } } override fun convertBack(value: AmountsDomainModel): AmountsDataModel { - return value - .mapKeys { (id, _) -> id.value } - .mapNotNullValues { (_, amount) -> - when (amount) { - is NetworkStatus.Amount.Loaded -> amount.value - is NetworkStatus.Amount.NotFound -> null - } - } + return value.mapNotNull { + val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null + + CurrencyAmount( + id = currencyIdConverter.convertBack(value = it.key), + amount = amount.value, + ) + } } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt index b142323e74..d6878c7f82 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt @@ -15,14 +15,22 @@ internal object NetworkStatusDataModelConverter : Converter { val address = NetworkAddressConverter.convertBack(value = status.address) + val amountsConverter = NetworkAmountsConverter( + rawNetworkId = value.network.rawId, + derivationPath = value.network.derivationPath, + ) + val yieldSupplyStatusConverter = NetworkYieldSupplyStatusConverter( + rawNetworkId = value.network.rawId, + derivationPath = value.network.derivationPath, + ) NetworkStatusDM.Verified( networkId = NetworkStatusDM.ID(value = value.network.rawId), derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath), selectedAddress = address.selectedAddress, availableAddresses = address.addresses, - amounts = NetworkAmountsConverter.convertBack(value = status.amounts), - yieldSupplyStatuses = NetworkYieldSupplyStatusConverter.convertBack(status.yieldSupplyStatuses), + amounts = amountsConverter.convertBack(value = status.amounts), + yieldSupplyStatuses = yieldSupplyStatusConverter.convertBack(status.yieldSupplyStatuses), ) } is NetworkStatus.NoAccount -> { diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt index d34edbbe5b..4dddbb56bc 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt @@ -2,45 +2,43 @@ package com.tangem.data.networks.converters import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.utils.converter.TwoWayConverter -import com.tangem.utils.extensions.mapNotNullValues -private typealias YieldSupplyStatusDataModel = Map +private typealias YieldSupplyStatusDataModel = List private typealias YieldSupplyStatusDomainModel = Map -internal object NetworkYieldSupplyStatusConverter : - TwoWayConverter { +internal class NetworkYieldSupplyStatusConverter( + rawNetworkId: String, + derivationPath: Network.DerivationPath, +) : TwoWayConverter { + + private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath) override fun convert(value: YieldSupplyStatusDataModel): YieldSupplyStatusDomainModel { - return value - .mapKeys { CryptoCurrency.ID.fromValue(value = it.key) } - .mapValues { (_, yieldSupplyStatus) -> - if (yieldSupplyStatus != null) { - YieldSupplyStatus( - isActive = yieldSupplyStatus.isActive, - isInitialized = yieldSupplyStatus.isInitialized, - isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, - ) - } else { - null - } - } + return value.associate { + val id = currencyIdConverter.convert(value = it.id) + val status = YieldSupplyStatus( + isActive = it.isActive, + isInitialized = it.isInitialized, + isAllowedToSpend = it.isAllowedToSpend, + ) + + id to status + } } override fun convertBack(value: YieldSupplyStatusDomainModel): YieldSupplyStatusDataModel { - return value - .mapKeys { (id, _) -> id.value } - .mapNotNullValues { (_, yieldSupplyStatus) -> - if (yieldSupplyStatus != null) { - NetworkStatusDM.YieldSupplyStatus( - isActive = yieldSupplyStatus.isActive, - isInitialized = yieldSupplyStatus.isInitialized, - isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, - ) - } else { - null - } - } + return value.mapNotNull { (currencyId, yieldSupplyStatus) -> + if (yieldSupplyStatus == null) return@mapNotNull null + + NetworkStatusDM.YieldSupplyStatus( + id = currencyIdConverter.convertBack(value = currencyId), + isActive = yieldSupplyStatus.isActive, + isInitialized = yieldSupplyStatus.isInitialized, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + ) + } } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt index 40a48b378f..287e82b3fa 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt @@ -22,14 +22,26 @@ internal object SimpleNetworkStatusConverter : Converter { NetworkStatus.Verified( address = address, - amounts = NetworkAmountsConverter.convert(value = value.amounts), + amounts = amountsConverter.convert(value = value.amounts), pendingTransactions = emptyMap(), source = StatusSource.CACHE, - yieldSupplyStatuses = NetworkYieldSupplyStatusConverter.convert(value = value.yieldSupplyStatuses), + yieldSupplyStatuses = yieldSupplyStatusConverter.convert(value = value.yieldSupplyStatuses), ) } is NetworkStatusDM.NoAccount -> { 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..43648cfd89 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 @@ -38,6 +40,7 @@ internal object NetworkDataModule { dispatchers: CoroutineDispatcherProvider, ): NetworksStatusesStore { return DefaultNetworksStatusesStore( + context = context, runtimeStore = RuntimeSharedStore(), persistenceDataStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( @@ -45,7 +48,7 @@ internal object NetworkDataModule { types = mapWithStringKeyTypes(valueTypes = setTypes()), defaultValue = emptyMap(), ), - produceFile = { context.dataStoreFile(fileName = "networks_statuses") }, + produceFile = { context.dataStoreFile(fileName = "networks_statuses_2") }, scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), ), dispatchers = dispatchers, @@ -67,4 +70,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..9cba360e2e 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 @@ -1,5 +1,6 @@ package com.tangem.data.networks.store +import android.content.Context import androidx.datastore.core.DataStore import com.tangem.data.networks.converters.NetworkStatusDataModelConverter import com.tangem.data.networks.converters.SimpleNetworkStatusConverter @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import timber.log.Timber +import java.io.File internal typealias WalletIdWithSimpleStatus = Map> internal typealias WalletIdWithStatusDM = Map> @@ -27,11 +29,13 @@ internal typealias WalletIdWithStatusDM = Map> /** * Default implementation of [NetworksStatusesStore] * + * @param context context * @property runtimeStore runtime store * @property persistenceDataStore persistence store * @param dispatchers dispatchers */ internal class DefaultNetworksStatusesStore( + context: Context, private val runtimeStore: RuntimeSharedStore, private val persistenceDataStore: DataStore, dispatchers: CoroutineDispatcherProvider, @@ -41,6 +45,16 @@ internal class DefaultNetworksStatusesStore( init { scope.launch { + try { + val oldFile = File(context.filesDir, "datastore/networks_statuses") + + if (oldFile.exists()) { + oldFile.delete() + } + } catch (e: Exception) { + Timber.e(e, "Error while deleting old networks statuses datastore file") + } + val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch runtimeStore.store( @@ -96,6 +110,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/converters/CurrencyIdConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt new file mode 100644 index 0000000000..dbc7712de6 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt @@ -0,0 +1,190 @@ +package com.tangem.data.networks.converters + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CurrencyIdConverterTest { + + private val rawNetworkId = "ETH" + private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") + private val derivationPathHashCode = "-1843072795" + private val converter = CurrencyIdConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = runCatching { converter.convert(value = model.value) } + + // Assert + actual + .onSuccess { + Truth.assertThat(it).isEqualTo(model.expected.getOrNull()) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it).hasMessageThat().isEqualTo(expected.message) + } + } + + private fun provideTestModels(): Collection = listOf( + // create coin id + ConvertModel( + value = CurrencyId.createCoinId("ethereum"), + expected = Result.success( + CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩ethereum"), + ), + ), + ConvertModel( + value = CurrencyId.createCoinId(""), + expected = Result.failure( + IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"), + ), + ), + ConvertModel( + value = CurrencyId.createCoinId(" "), + expected = Result.failure( + IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"), + ), + ), + // create token id + ConvertModel( + value = CurrencyId.createTokenId( + rawTokenId = "usdt", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + expected = Result.success( + CryptoCurrency.ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + ), + ), + ConvertModel( + value = CurrencyId.createTokenId( + rawTokenId = null, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + expected = Result.success( + CryptoCurrency.ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + ), + ), + ConvertModel( + value = CurrencyId.createTokenId( + rawTokenId = "", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + expected = Result.success( + CryptoCurrency.ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + ), + ), + ConvertModel( + value = CurrencyId.createTokenId( + rawTokenId = " ", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + expected = Result.success( + CryptoCurrency.ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + ), + ), + ConvertModel( + value = CurrencyId.createTokenId( + rawTokenId = "usdt", + contractAddress = "", + ), + expected = Result.success( + CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"), + ), + ), + ConvertModel( + value = CurrencyId.createTokenId( + rawTokenId = "usdt", + contractAddress = " ", + ), + expected = Result.success( + CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"), + ), + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = runCatching { converter.convertBack(value = model.value) } + + // Assert + actual + .onSuccess { + Truth.assertThat(it).isEqualTo(model.expected.getOrNull()) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it).hasMessageThat().isEqualTo(expected.message) + } + } + + private fun provideTestModels(): Collection = listOf( + ConvertBackModel( + value = CryptoCurrency.ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum"), + expected = Result.success( + CurrencyId.createCoinId("ethereum"), + ), + ), + ConvertBackModel( + value = CryptoCurrency.ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + expected = Result.success( + CurrencyId.createTokenId( + rawTokenId = "usdt", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + ), + ), + ConvertBackModel( + value = CryptoCurrency.ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + expected = Result.success( + CurrencyId.createTokenId( + rawTokenId = null, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + ), + ), + ) + } + + data class ConvertModel(val value: CurrencyId, val expected: Result) + + data class ConvertBackModel(val value: CryptoCurrency.ID, val expected: Result) +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt index 591f85f78d..d4866eeabf 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAddressConverterTest.kt @@ -1,16 +1,17 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.network.NetworkAddress import org.junit.jupiter.api.Nested import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource /** [REDACTED_AUTHOR] */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkAddressConverterTest { @Nested @@ -18,7 +19,7 @@ internal class NetworkAddressConverterTest { inner class Convert { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun convert(model: ConvertModel) { // Act val actual = runCatching { NetworkAddressConverter.convert(value = model.value) } @@ -190,7 +191,7 @@ internal class NetworkAddressConverterTest { inner class ConvertBack { @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun convertBack(model: ConvertBackModel) { // Act val actual = NetworkAddressConverter.convertBack(value = model.value) diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt index 38dfb7d057..5bbe4daf2d 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt @@ -1,77 +1,99 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId import com.tangem.domain.models.currency.CryptoCurrency.ID -import com.tangem.domain.models.currency.CryptoCurrency.ID.Body -import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix -import com.tangem.domain.models.network.NetworkStatus.Amount +import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkStatus.Amount.Loaded import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance import java.math.BigDecimal /** [REDACTED_AUTHOR] */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkAmountsConverterTest { + private val rawNetworkId = "ETH" + private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") + private val derivationPathHashCode = "-1843072795" + private val converter = NetworkAmountsConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath) + @Test fun convert() { // Arrange - val value = mapOf( - "coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO, - "coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE, + val value = listOf( + NetworkStatusDM.CurrencyAmount(CurrencyId.createCoinId(coinId = "ethereum"), BigDecimal.ONE), + NetworkStatusDM.CurrencyAmount( + id = CurrencyId.createTokenId( + rawTokenId = "usdt", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + amount = BigDecimal.ZERO, + ), + NetworkStatusDM.CurrencyAmount( + id = CurrencyId.createTokenId( + rawTokenId = null, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + amount = BigDecimal.TEN, + ), ) // Act - val actual = NetworkAmountsConverter.convert(value) + val actual = converter.convert(value) // Assert val expected = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "BCH"), - suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"), + ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), + ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.ZERO), - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to Loaded(value = BigDecimal.ONE), + ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + ) to Loaded(value = BigDecimal.TEN), ) - Truth.assertThat(actual).isEqualTo(expected) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) } @Test fun convertBack() { // Arrange val value = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "BCH"), - suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"), + ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), + ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.ZERO), - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to Loaded(value = BigDecimal.ONE), - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "BTC"), - suffix = ID.Suffix.RawID(rawId = "bitcoin"), - ) to Amount.NotFound, + ID.fromValue( + value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + ) to Loaded(value = BigDecimal.TEN), ) // Act - val actual = NetworkAmountsConverter.convertBack(value) + val actual = converter.convertBack(value) // Assert - val expected = mapOf( - "coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO, - "coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE, + val expected = listOf( + NetworkStatusDM.CurrencyAmount(CurrencyId.createCoinId(coinId = "ethereum"), BigDecimal.ONE), + NetworkStatusDM.CurrencyAmount( + id = CurrencyId.createTokenId( + rawTokenId = "usdt", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + amount = BigDecimal.ZERO, + ), + NetworkStatusDM.CurrencyAmount( + id = CurrencyId.createTokenId( + rawTokenId = null, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ), + amount = BigDecimal.TEN, + ), ) - Truth.assertThat(actual).isEqualTo(expected) + Truth.assertThat(actual).containsExactlyElementsIn(expected) } } \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt index a254164b1a..0d16b0ac0a 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt @@ -2,7 +2,9 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.ProvideTestModels import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.datasource.local.network.entity.NetworkStatusDM.* import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency.ID import com.tangem.domain.models.currency.CryptoCurrency.ID.Body @@ -14,7 +16,6 @@ import com.tangem.domain.models.network.NetworkStatus.Amount import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource import java.math.BigDecimal /** @@ -26,7 +27,7 @@ internal class NetworkStatusDataModelConverterTest { private val network: Network = MockCryptoCurrencyFactory().ethereum.network @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun convert(model: ConvertModel) { // Act val actual = NetworkStatusDataModelConverter.convert(value = model.value) @@ -48,11 +49,7 @@ internal class NetworkStatusDataModelConverterTest { ), ), amounts = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "BCH"), - suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"), - ) to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue(value = "coin⟨ETH→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), ID( prefix = Prefix.COIN_PREFIX, body = Body.NetworkId(rawId = "BTC"), @@ -61,11 +58,7 @@ internal class NetworkStatusDataModelConverterTest { ), pendingTransactions = mapOf(), // doesn't matter yieldSupplyStatuses = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "BCH"), - suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"), - ) to YieldSupplyStatus( + ID.fromValue(value = "token⟨ETH→0⟩usdt⚓0x1") to YieldSupplyStatus( isActive = false, isInitialized = false, isAllowedToSpend = false, @@ -79,22 +72,25 @@ internal class NetworkStatusDataModelConverterTest { source = StatusSource.ACTUAL, // doesn't matter ), ), - expected = NetworkStatusDM.Verified( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + expected = Verified( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "", - type = NetworkStatusDM.DerivationPath.Type.NONE, + type = DerivationPath.Type.NONE, ), selectedAddress = "0x123", availableAddresses = setOf( - NetworkStatusDM.Address( + Address( value = "0x123", - type = NetworkStatusDM.Address.Type.Primary, + type = Address.Type.Primary, ), ), - amounts = mapOf("coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO), - yieldSupplyStatuses = mapOf( - "coin⟨BCH⟩bitcoin-cash" to NetworkStatusDM.YieldSupplyStatus( + amounts = listOf( + CurrencyAmount(CurrencyId.createCoinId("ethereum"), BigDecimal.ZERO), + ), + yieldSupplyStatuses = listOf( + YieldSupplyStatus( + id = CurrencyId.createTokenId("usdt", "0x1"), isActive = false, isInitialized = false, isAllowedToSpend = false, @@ -120,17 +116,17 @@ internal class NetworkStatusDataModelConverterTest { source = StatusSource.ACTUAL, // doesn't matter ), ), - expected = NetworkStatusDM.NoAccount( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + expected = NoAccount( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "", - type = NetworkStatusDM.DerivationPath.Type.NONE, + type = DerivationPath.Type.NONE, ), selectedAddress = "0x123", availableAddresses = setOf( - NetworkStatusDM.Address( + Address( value = "0x123", - type = NetworkStatusDM.Address.Type.Primary, + type = Address.Type.Primary, ), ), amountToCreateAccount = BigDecimal.ONE, diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt index c1ef31d529..1c21deff5d 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt @@ -2,82 +2,74 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId import com.tangem.domain.models.currency.CryptoCurrency.ID -import com.tangem.domain.models.currency.CryptoCurrency.ID.Body -import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix +import com.tangem.domain.models.network.Network import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkYieldSupplyStatusConverterTest { + private val rawNetworkId = "ETH" + private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") + private val derivationPathHashCode = "-1843072795" + private val converter = NetworkYieldSupplyStatusConverter(rawNetworkId, derivationPath) + + private val domainStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + ) + @Test fun convert() { // Arrange - val value = mapOf( - "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( - isActive = false, - isInitialized = false, - isAllowedToSpend = false, - ), - "coin⟨ETH→12367123⟩ethereum" to null, + val value = listOf( + createDataStatus(id = CurrencyId.createCoinId("ethereum")), + createDataStatus(id = CurrencyId.createTokenId("usdt", "0x1")), ) // Act - val actual = NetworkYieldSupplyStatusConverter.convert(value) + val actual = converter.convert(value) // Assert val expected = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "ETH"), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to YieldSupplyStatus( - isActive = false, - isInitialized = false, - isAllowedToSpend = false, - ), - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to null, + ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus, + ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, ) - Truth.assertThat(actual).isEqualTo(expected) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) } @Test fun convertBack() { // Arrange val value = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "ETH"), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to YieldSupplyStatus( - isActive = false, - isInitialized = false, - isAllowedToSpend = false, - ), - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to null, + ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus, + ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, + ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdc⚓0x1") to null, ) // Act - val actual = NetworkYieldSupplyStatusConverter.convertBack(value) + val actual = converter.convertBack(value) // Assert - val expected = mapOf( - "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( - isActive = false, - isInitialized = false, - isAllowedToSpend = false, - ), + val expected = listOf( + createDataStatus(id = CurrencyId.createCoinId("ethereum")), + createDataStatus(id = CurrencyId.createTokenId("usdt", "0x1")), ) - Truth.assertThat(actual).isEqualTo(expected) + Truth.assertThat(actual).containsExactlyElementsIn(expected) + } + + private fun createDataStatus(id: CurrencyId): NetworkStatusDM.YieldSupplyStatus { + return NetworkStatusDM.YieldSupplyStatus( + id = id, + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + ) } } \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index 3fe7708a7e..2ca9c9e06c 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -2,12 +2,12 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.datasource.local.network.entity.NetworkStatusDM.* import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency.ID -import com.tangem.domain.models.currency.CryptoCurrency.ID.Body -import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus @@ -15,7 +15,6 @@ import com.tangem.domain.models.network.NetworkStatus.Amount import com.tangem.domain.models.yield.supply.YieldSupplyStatus import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource import java.math.BigDecimal /** @@ -27,7 +26,7 @@ internal class SimpleNetworkStatusConverterTest { private val network: Network = MockCryptoCurrencyFactory().ethereum.network @ParameterizedTest - @MethodSource("provideTestModels") + @ProvideTestModels fun convert(model: ConvertModel) { // Act val actual = runCatching { SimpleNetworkStatusConverter.convert(value = model.value) } @@ -48,34 +47,28 @@ internal class SimpleNetworkStatusConverterTest { private fun provideTestModels() = listOf( // region Verified ConvertModel( - value = NetworkStatusDM.Verified( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = Verified( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "0x1", availableAddresses = setOf( - NetworkStatusDM.Address( - value = "0x1", - type = NetworkStatusDM.Address.Type.Primary, - ), - NetworkStatusDM.Address( - value = "0x2", - type = NetworkStatusDM.Address.Type.Secondary, - ), + Address(value = "0x1", type = Address.Type.Primary), + Address(value = "0x2", type = Address.Type.Secondary), ), - amounts = mapOf( - "coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO, - "coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE, + amounts = listOf( + CurrencyAmount(CurrencyId.createCoinId("ethereum"), BigDecimal.ZERO), + CurrencyAmount(CurrencyId.createTokenId("usdt", "0x1"), BigDecimal.ZERO), ), - yieldSupplyStatuses = mapOf( - "coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus( + yieldSupplyStatuses = listOf( + YieldSupplyStatus( + id = CurrencyId.createCoinId("ethereum"), isActive = false, isInitialized = false, isAllowedToSpend = false, ), - "coin⟨ETH⟩ethereum" to null, ), ), expected = SimpleNetworkStatus( @@ -101,33 +94,16 @@ internal class SimpleNetworkStatusConverterTest { ), ), amounts = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "BCH"), - suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"), - ) to Amount.Loaded(value = BigDecimal.ZERO), - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to Amount.Loaded(value = BigDecimal.ONE), + ID.fromValue("coin⟨ETH→3046160⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue("token⟨ETH→3046160⟩usdt⚓0x1") to Amount.Loaded(value = BigDecimal.ZERO), ), pendingTransactions = emptyMap(), yieldSupplyStatuses = mapOf( - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "ETH"), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to YieldSupplyStatus( + ID.fromValue("coin⟨ETH→3046160⟩ethereum") to YieldSupplyStatus( isActive = false, isInitialized = false, isAllowedToSpend = false, ), - ID( - prefix = Prefix.COIN_PREFIX, - body = Body.NetworkId(rawId = "ETH"), - suffix = ID.Suffix.RawID(rawId = "ethereum"), - ) to null, ), source = StatusSource.CACHE, ), @@ -137,21 +113,21 @@ internal class SimpleNetworkStatusConverterTest { // region NoAccount ConvertModel( - value = NetworkStatusDM.NoAccount( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = NoAccount( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "0x1", availableAddresses = setOf( - NetworkStatusDM.Address( + Address( value = "0x1", - type = NetworkStatusDM.Address.Type.Primary, + type = Address.Type.Primary, ), - NetworkStatusDM.Address( + Address( value = "0x2", - type = NetworkStatusDM.Address.Type.Secondary, + type = Address.Type.Secondary, ), ), amountToCreateAccount = BigDecimal.ONE, @@ -189,83 +165,83 @@ internal class SimpleNetworkStatusConverterTest { // region Error ConvertModel( - value = NetworkStatusDM.Verified( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = Verified( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "0x1", availableAddresses = setOf( - NetworkStatusDM.Address( + Address( value = "0x2", - type = NetworkStatusDM.Address.Type.Primary, + type = Address.Type.Primary, ), ), - amounts = emptyMap(), - yieldSupplyStatuses = emptyMap(), + amounts = emptyList(), + yieldSupplyStatuses = emptyList(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), ), ), ConvertModel( - value = NetworkStatusDM.Verified( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = Verified( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "0x1", availableAddresses = setOf(), - amounts = emptyMap(), - yieldSupplyStatuses = emptyMap(), + amounts = emptyList(), + yieldSupplyStatuses = emptyList(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), ), ), ConvertModel( - value = NetworkStatusDM.Verified( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = Verified( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "", availableAddresses = setOf( - NetworkStatusDM.Address( + Address( value = "0x1", - type = NetworkStatusDM.Address.Type.Primary, + type = Address.Type.Primary, ), - NetworkStatusDM.Address( + Address( value = "0x2", - type = NetworkStatusDM.Address.Type.Secondary, + type = Address.Type.Secondary, ), ), - amounts = emptyMap(), - yieldSupplyStatuses = emptyMap(), + amounts = emptyList(), + yieldSupplyStatuses = emptyList(), ), expected = Result.failure( exception = IllegalArgumentException("Selected address must not be null"), ), ), ConvertModel( - value = NetworkStatusDM.NoAccount( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = NoAccount( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "", availableAddresses = setOf( - NetworkStatusDM.Address( + Address( value = "0x1", - type = NetworkStatusDM.Address.Type.Primary, + type = Address.Type.Primary, ), - NetworkStatusDM.Address( + Address( value = "0x2", - type = NetworkStatusDM.Address.Type.Secondary, + type = Address.Type.Secondary, ), ), amountToCreateAccount = BigDecimal.ONE, @@ -276,17 +252,17 @@ internal class SimpleNetworkStatusConverterTest { ), ), ConvertModel( - value = NetworkStatusDM.NoAccount( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = NoAccount( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "0x1", availableAddresses = setOf( - NetworkStatusDM.Address( + Address( value = "0x2", - type = NetworkStatusDM.Address.Type.Primary, + type = Address.Type.Primary, ), ), amountToCreateAccount = BigDecimal.ONE, @@ -297,11 +273,11 @@ internal class SimpleNetworkStatusConverterTest { ), ), ConvertModel( - value = NetworkStatusDM.NoAccount( - networkId = NetworkStatusDM.ID(network.rawId), - derivationPath = NetworkStatusDM.DerivationPath( + value = NoAccount( + networkId = ID(network.rawId), + derivationPath = DerivationPath( value = "card", - type = NetworkStatusDM.DerivationPath.Type.CARD, + type = DerivationPath.Type.CARD, ), selectedAddress = "0x1", availableAddresses = setOf(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt index dfed3c800b..17c16671d5 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/GetTest.kt @@ -11,6 +11,7 @@ import com.tangem.data.networks.toSimple import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Test @@ -23,6 +24,7 @@ internal class GetTest { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt index 3c979ceb29..63d2daa46b 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/InitializationTest.kt @@ -30,6 +30,7 @@ internal class InitializationTest { every { persistenceStore.data } returns emptyFlow() DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), @@ -44,6 +45,7 @@ internal class InitializationTest { val persistenceStore = MockStateDataStore(default = emptyMap()) DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), @@ -66,6 +68,7 @@ internal class InitializationTest { } DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt index 846bf9decb..d310e1bdde 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreStatusTest.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,6 +28,7 @@ internal class ParameterizedStoreStatusTest(private val model: Model) { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt index adae5ca5c7..7e62ff9843 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreSuccessTest.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,6 +28,7 @@ internal class ParameterizedStoreSuccessTest(private val model: Model) { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt index 60e055e9ec..99436f876e 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/ParameterizedStoreTest.kt @@ -10,6 +10,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -26,6 +27,7 @@ internal class ParameterizedStoreTest(private val model: Model) { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt index f023eb27a1..cfb2eb16f0 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsCacheTest.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,6 +25,7 @@ internal class SetSourceAsCacheTest { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt index 655bf8d83d..1136ce2921 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/SetSourceAsOnlyCacheTest.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,6 +25,7 @@ internal class SetSourceAsOnlyCacheTest { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt index 4c75c7b7c0..aa545d3ba4 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/StoreStatusTest.kt @@ -11,6 +11,7 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,6 +25,7 @@ internal class StoreStatusTest { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt index e1177104a8..ebf54b27c7 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/StoreSuccessTest.kt @@ -10,6 +10,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -23,6 +24,7 @@ internal class StoreSuccessTest { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt index 3f1c2ad5eb..eb5cd705b3 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/StoreTest.kt @@ -10,6 +10,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -23,6 +24,7 @@ internal class StoreTest { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt index e4114806e8..785c1b8d1b 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/UpdateStatusSourceTest.kt @@ -14,6 +14,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,6 +28,7 @@ internal class UpdateStatusSourceTest { private val persistenceStore = MockStateDataStore(default = emptyMap()) private val store = DefaultNetworksStatusesStore( + context = mockk(), runtimeStore = runtimeStore, persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), 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/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt index 072cf92212..6d67e11d9d 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt @@ -14,6 +14,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -54,7 +55,7 @@ internal class DefaultSwapTransactionRepository( storeTransactionState( txId = transaction.txId, status = it, - refundTokenCurrency = null, + accountWithCurrency = null, ) } appPreferencesStore.editData { mutablePreferences -> @@ -177,7 +178,7 @@ internal class DefaultSwapTransactionRepository( override suspend fun storeTransactionState( txId: String, status: SwapStatusModel, - refundTokenCurrency: CryptoCurrency?, + accountWithCurrency: Pair?, ) { appPreferencesStore.editData { mutablePreferences -> val savedMap = mutablePreferences.getObjectMap( @@ -187,8 +188,8 @@ internal class DefaultSwapTransactionRepository( val updatesMap = savedMap.toMutableMap() updatesMap[txId] = savedStatusConverter.convert( status.copy( - refundTokensResponse = refundTokenCurrency?.let { - userTokensResponseFactory.createResponseToken(refundTokenCurrency) + refundTokensResponse = accountWithCurrency?.let { (accountId, currency) -> + userTokensResponseFactory.createResponseToken(currency, accountId) }, ), ) diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt index d81f412486..dca9f020fc 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -25,10 +25,12 @@ internal class SavedSwapTransactionListConverter( fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( - value.fromCryptoCurrency, + currency = value.fromCryptoCurrency, + accountId = null, ), toTokensResponse = userTokensResponseFactory.createResponseToken( - value.toCryptoCurrency, + currency = value.toCryptoCurrency, + accountId = null, ), transactions = savedSwapTransactionConverter.convertList(value.transactions), ) @@ -74,8 +76,11 @@ internal class SavedSwapTransactionListConverter( userWalletId = userWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, - fromTokensResponse = userTokensResponseFactory.createResponseToken(fromCryptoCurrency), - toTokensResponse = userTokensResponseFactory.createResponseToken(toCryptoCurrency), + fromTokensResponse = userTokensResponseFactory.createResponseToken( + currency = fromCryptoCurrency, + accountId = null, + ), + toTokensResponse = userTokensResponseFactory.createResponseToken(currency = toCryptoCurrency, accountId = null), transactions = tokenTransactions, ) } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesProducerModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesProducerModule.kt deleted file mode 100644 index 03fa952be2..0000000000 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesProducerModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.data.tokens.di - -import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesProducer -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface MultiWalletCryptoCurrenciesProducerModule { - - @Singleton - @Binds - fun bindMultiWalletCryptoCurrenciesProducerFactory( - impl: DefaultMultiWalletCryptoCurrenciesProducer.Factory, - ): MultiWalletCryptoCurrenciesProducer.Factory -} \ 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/DefaultDataForReceiveFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultDataForReceiveFactory.kt new file mode 100644 index 0000000000..76a1ab3929 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultDataForReceiveFactory.kt @@ -0,0 +1,66 @@ +package com.tangem.data.pay + +import arrow.core.Either +import com.squareup.moshi.Moshi +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.core.error.UniversalError +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.pay.util.TangemPayErrorConverter +import com.tangem.data.pay.util.TangemPayWalletsManager +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.ReceiveAddressModel.NameService +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.pay.DataForReceive +import com.tangem.domain.pay.DataForReceiveFactory +import kotlinx.coroutines.CancellationException +import timber.log.Timber +import javax.inject.Inject + +private const val TAG = "TangemPay: TokenReceiveConfigFactory" + +internal class DefaultDataForReceiveFactory @Inject constructor( + @NetworkMoshi moshi: Moshi, + private val tangemPayWalletsManager: TangemPayWalletsManager, + excludedBlockchains: ExcludedBlockchains, +) : DataForReceiveFactory { + + private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + CryptoCurrencyFactory(excludedBlockchains) + } + private val errorConverter by lazy(mode = LazyThreadSafetyMode.NONE) { TangemPayErrorConverter(moshi) } + + override fun getDataForReceive(depositAddress: String, chainId: Int): Either { + return try { + val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking() + + /** + * Create [CryptoCurrency.Coin] only for F&F. + * Later will use [CryptoCurrency.Token] when contractAddresses will be provided by BFF. + */ + val currency = cryptoCurrencyFactory.createCoin( + chainId = chainId, + extraDerivationPath = null, + userWallet = wallet, + ) ?: error("Cannot create crypto currency from chainId $chainId") + + val result = DataForReceive( + currency = currency, + walletId = wallet.walletId, + receiveAddress = listOf(ReceiveAddressModel(nameService = NameService.Default, value = depositAddress)), + ) + + Either.Right(result) + } catch (exception: Exception) { + when (exception) { + is CancellationException -> { + throw exception + } + else -> { + Timber.tag(TAG).e(exception) + Either.Left(errorConverter.convert(exception)) + } + } + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 8f407caa11..c6222c62c7 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -1,9 +1,11 @@ package com.tangem.data.pay.di +import com.tangem.data.pay.DefaultDataForReceiveFactory import com.tangem.data.pay.repository.DefaultCardDetailsRepository import com.tangem.data.pay.repository.DefaultKycRepository import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository import com.tangem.data.pay.repository.DefaultOnboardingRepository +import com.tangem.domain.pay.DataForReceiveFactory import com.tangem.domain.pay.repository.CardDetailsRepository import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.pay.repository.OnboardingRepository @@ -37,6 +39,10 @@ internal interface TangemPayDataModule { @Singleton fun bindCardDetailsRepository(repository: DefaultCardDetailsRepository): CardDetailsRepository + @Binds + @Singleton + fun bindDataForReceiveFactory(factory: DefaultDataForReceiveFactory): DataForReceiveFactory + companion object { @Provides @Singleton diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt index 97b84bd4e9..2875b51a63 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt @@ -74,6 +74,7 @@ internal class DefaultCardDetailsRepository @Inject constructor( return when (env) { ApiEnvironment.DEV, ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, ApiEnvironment.STAGE, ApiEnvironment.MOCK, -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.dev 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..dbe40d9c20 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) + } + } } } @@ -78,33 +102,35 @@ internal class DefaultOnboardingRepository @Inject constructor( balance = balance.availableBalance, currencyCode = balance.currency, customerWalletAddress = paymentAccount.customerWalletAddress, + depositAddress = response.depositAddress, ) } else { null } 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/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt index 0865a2fd21..c52ec65d24 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt @@ -5,27 +5,19 @@ import com.squareup.moshi.Moshi import com.tangem.blockchain.common.Blockchain import com.tangem.core.error.UniversalError import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.pay.util.TangemPayErrorConverter +import com.tangem.data.pay.util.TangemPayWalletsManager import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.visa.TangemPayStorage -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.datasource.TangemPayAuthDataSource -import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.derivations.derivationStyleProvider -import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -37,9 +29,7 @@ internal class TangemPayRequestPerformer @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val tangemPayStorage: TangemPayStorage, private val authDataSource: TangemPayAuthDataSource, - private val userWalletsListManager: UserWalletsListManager, - private val userWalletsListRepository: UserWalletsListRepository, - private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val tangemPayWalletsManager: TangemPayWalletsManager, private val walletManagersFacade: WalletManagersFacade, private val networkFactory: NetworkFactory, ) { @@ -49,7 +39,7 @@ internal class TangemPayRequestPerformer @Inject constructor( private val refreshTokensMutex = Mutex() private var refreshTokensJob: Deferred? = null - private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) + private val errorConverter = TangemPayErrorConverter(moshi) suspend fun runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either { return try { @@ -62,7 +52,7 @@ internal class TangemPayRequestPerformer @Inject constructor( } else -> { Timber.tag(tag).e(exception) - Either.Left(mapError(exception)) + Either.Left(errorConverter.convert(exception)) } } } @@ -86,12 +76,6 @@ internal class TangemPayRequestPerformer @Inject constructor( ) } - private fun getWallets(): Flow> = if (hotWalletFeatureToggles.isHotWalletEnabled) { - userWalletsListRepository.userWallets.map { requireNotNull(it) } - } else { - userWalletsListManager.userWallets - } - private suspend fun performRequest( requestBlock: suspend (header: String) -> ApiResponse, getTokens: (suspend () -> VisaAuthTokens), @@ -143,11 +127,7 @@ internal class TangemPayRequestPerformer @Inject constructor( } private suspend fun fetchAuthInputData(): AuthInputData { - val userWallets = getWallets() - .filter { it.isNotEmpty() } - .first() - val wallet = userWallets.find { it is UserWallet.Cold } as? UserWallet.Cold - ?: error("Cannot find cold user wallet") + val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay() val network = networkFactory.create( blockchain = Blockchain.Polygon, @@ -179,21 +159,6 @@ internal class TangemPayRequestPerformer @Inject constructor( tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens) return tokens } - - private fun mapError(throwable: Throwable): UniversalError { - return if (throwable is ApiResponseError.HttpException) { - val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode - return runCatching { - visaErrorAdapter.fromJson(errorBody)?.error?.code ?: throwable.code.numericCode - }.map { - VisaApiError.fromBackendError(it) - }.getOrElse { - VisaApiError.UnknownWithoutCode - } - } else { - VisaApiError.UnknownWithoutCode - } - } } internal data class AuthInputData( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt new file mode 100644 index 0000000000..d5f0ebf004 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.data.pay.util + +import com.squareup.moshi.Moshi +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.converter.Converter + +class TangemPayErrorConverter(moshi: Moshi) : Converter { + + private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) + + override fun convert(value: Throwable): UniversalError { + return if (value is ApiResponseError.HttpException) { + val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode + return runCatching { + visaErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode + }.map { + VisaApiError.fromBackendError(it) + }.getOrElse { + VisaApiError.UnknownWithoutCode + } + } else { + VisaApiError.UnknownWithoutCode + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayWalletsManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayWalletsManager.kt new file mode 100644 index 0000000000..32c775d61a --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayWalletsManager.kt @@ -0,0 +1,34 @@ +package com.tangem.data.pay.util + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.features.hotwallet.HotWalletFeatureToggles +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +internal class TangemPayWalletsManager @Inject constructor( + private val manager: UserWalletsListManager, + private val repository: UserWalletsListRepository, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, +) { + + suspend fun getDefaultWalletForTangemPay(): UserWallet.Cold { + val userWalletsFlow = if (useNewRepository()) repository.userWallets else manager.userWallets + val userWallets = userWalletsFlow.filter { !it.isNullOrEmpty() }.first() + return findColdWallet(userWallets) + } + + fun getDefaultWalletForTangemPayBlocking(): UserWallet.Cold { + val userWallets = if (useNewRepository()) repository.userWallets.value else manager.userWalletsSync + return findColdWallet(userWallets) + } + + private fun useNewRepository(): Boolean = hotWalletFeatureToggles.isHotWalletEnabled + + private fun findColdWallet(userWallets: List?): UserWallet.Cold { + return userWallets?.find { it is UserWallet.Cold } as? UserWallet.Cold + ?: error("Cannot find cold user wallet") + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt index 939794afce..a227353e33 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaActivationRepository.kt @@ -169,6 +169,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor( return when (env) { ApiEnvironment.DEV, ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, ApiEnvironment.STAGE, ApiEnvironment.MOCK, -> rsaPublicKey.dev 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/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt index 7eb6f6e5fb..04aa38e0d9 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaApiRequestMaker.kt @@ -54,7 +54,7 @@ internal class VisaApiRequestMaker @Inject constructor( userWalletsStore.update(userWalletId) { userWallet -> userWallet.requireColdWallet().copy( scanResponse = userWallet.scanResponse.copy( - visaCardActivationStatus = VisaCardActivationStatus.RefreshTokenExpired, + // visaCardActivationStatus = VisaCardActivationStatus.RefreshTokenExpired, ), ) } @@ -65,9 +65,9 @@ internal class VisaApiRequestMaker @Inject constructor( userWalletsStore.update(userWalletId) { userWallet -> userWallet.requireColdWallet().copy( scanResponse = userWallet.scanResponse.copy( - visaCardActivationStatus = VisaCardActivationStatus.Activated( - visaAuthTokens = newTokens, - ), + // visaCardActivationStatus = VisaCardActivationStatus.Activated( + // visaAuthTokens = newTokens, + // ), ), ) } @@ -93,8 +93,9 @@ internal class VisaApiRequestMaker @Inject constructor( @Throws private fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens { val userWallet = findVisaUserWallet(userWalletId) - val status = userWallet.requireColdWallet().scanResponse.visaCardActivationStatus - ?: error("Visa card activation status not found") + // val status = userWallet.requireColdWallet().scanResponse.visaCardActivationStatus + // ?: error("Visa card activation status not found") + val status: VisaCardActivationStatus = TODO("Fix visaCardActivationStatus retrieval") if (status is VisaCardActivationStatus.RefreshTokenExpired) { throw RefreshTokenExpiredException() 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..e910bf695e 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,16 @@ interface AccountsCRUDRepository { */ suspend fun saveAccounts(accountList: AccountList) + /** + * Save account + * + * @param account account to be saved + */ + suspend fun saveAccount(account: Account.CryptoPortfolio) + + /** Synchronizes tokens for a specific [userWalletId] with remote data source */ + suspend fun syncTokens(userWalletId: UserWalletId) + /** * 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/IsAccountsModeEnabledUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt index 9c295b058d..1a93dc67e5 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt @@ -34,6 +34,7 @@ class IsAccountsModeEnabledUseCase( combine(flows = totalAccountsCountList) { it.toList().isModeEnabled() } } .onEmpty { emit(false) } + .distinctUntilChanged() } suspend fun invokeSync(): Boolean { 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..730bff6aa4 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,103 @@ package com.tangem.domain.account.status.usecase import arrow.core.Option import arrow.core.none +import arrow.core.toOption +import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusFinder import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +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 AccountCryptoCurrencyStatusFinder( + accountStatusList = this, + currencyId = currencyId, + network = network, + ) + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetCryptoCurrencyActionsUseCaseV2.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetCryptoCurrencyActionsUseCaseV2.kt new file mode 100644 index 0000000000..9c7c078cd8 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetCryptoCurrencyActionsUseCaseV2.kt @@ -0,0 +1,53 @@ +package com.tangem.domain.account.status.usecase + +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusFinder +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.model.TokenActionsState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.transformLatest + +/** + * Use case to retrieve the available actions for a specific cryptocurrency associated with an account. + * + * @property accountsCRUDRepository repository for account CRUD operations. + * @property singleAccountStatusListSupplier supplier to get the list of account statuses. + * @property getCryptoCurrencyActionsUseCase use case to get the actions for a specific cryptocurrency status. + * +[REDACTED_AUTHOR] + */ +class GetCryptoCurrencyActionsUseCaseV2( + private val accountsCRUDRepository: AccountsCRUDRepository, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(accountId: AccountId, currency: CryptoCurrency): Flow { + return singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId = accountId.userWalletId), + ) + .transformLatest { accountStatusList -> + val accountCurrencyStatus = AccountCryptoCurrencyStatusFinder( + accountStatusList = accountStatusList, + currency = currency, + ) + + if (accountCurrencyStatus != null) { + val userWallet = accountsCRUDRepository.getUserWallet(userWalletId = accountId.userWalletId) + val actionsFlow = getCryptoCurrencyActionsUseCase( + userWallet = userWallet, + cryptoCurrencyStatus = accountCurrencyStatus.status, + ) + + emitAll(actionsFlow) + } + } + } +} \ 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..144421a34d --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt @@ -0,0 +1,322 @@ +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: CryptoCurrency? = null, + remove: CryptoCurrency? = null, + ): Either { + return invoke(accountId = accountId, add = listOfNotNull(add), remove = listOfNotNull(remove)) + } + + suspend operator fun invoke( + accountId: AccountId, + add: List = emptyList(), + remove: List = emptyList(), + ): 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() + } + } + + suspend fun add( + accountId: AccountId, + networkId: String, + contractAddress: String, + ): Either = eitherOn(dispatchers.default) { + val userWalletId = accountId.userWalletId + + withContext(NonCancellable) { + val account = getAccount(accountId = accountId) + + val foundToken = account.cryptoCurrencies + .filterIsInstance() + .firstOrNull { + it.network.backendId == networkId && + !it.isCustom && + it.contractAddress.equals(contractAddress, true) + } + + if (foundToken != null) return@withContext foundToken + + val tokenToAdd = findToken(userWalletId, contractAddress, networkId) + + refreshBalances(userWalletId = userWalletId, currencies = listOf(tokenToAdd)).joinAll() + + tokenToAdd + } + } + + 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 Raise.findToken( + userWalletId: UserWalletId, + networkId: String, + contractAddress: String, + ): CryptoCurrency.Token { + return catch( + block = { + currenciesRepository.createTokenCurrency( + userWalletId = userWalletId, + networkId = networkId, + contractAddress = contractAddress, + ) + }, + catch = ::raise, + ) + } + + 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), + ), + ) + + accountsCRUDRepository.syncTokens(userWalletId) + } + + private suspend fun refreshYieldBalances(userWalletId: UserWalletId, currencies: List) { + val stakingIds = currencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() + } + + multiYieldBalanceFetcher( + params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds), + ) + } + + private suspend fun refreshQuotes(currencies: List) { + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( + currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + appCurrencyId = null, + ), + ) + } + + private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List): List { + if (currencies.isEmpty()) return emptyList() + + return coroutineScope { + 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/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt new file mode 100644 index 0000000000..6f29996eab --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -0,0 +1,88 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.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.lib.crypto.derivation.AccountNodeRecognizer + +/** + * Finds the status of a specific cryptocurrency associated with an account from the provided account status list. + * +[REDACTED_AUTHOR] + */ +internal object AccountCryptoCurrencyStatusFinder { + + /** + * Retrieves the [AccountCryptoCurrencyStatus] for the specified [currency] from the given [accountStatusList]. + * + * @param accountStatusList the list of account statuses to search within. + * @param currency the cryptocurrency whose status is to be retrieved. + * @return the [AccountCryptoCurrencyStatus] if found, otherwise null. + */ + operator fun invoke(accountStatusList: AccountStatusList, currency: CryptoCurrency): AccountCryptoCurrencyStatus? { + return invoke( + accountStatusList = accountStatusList, + currencyId = currency.id, + network = currency.network, + ) + } + + operator fun invoke( + accountStatusList: AccountStatusList, + currencyId: CryptoCurrency.ID, + network: Network?, + ): AccountCryptoCurrencyStatus? { + return accountStatusList.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/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/build.gradle.kts b/domain/card/build.gradle.kts index 8fd9a047e4..a7eff175d4 100644 --- a/domain/card/build.gradle.kts +++ b/domain/card/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.visa.models) implementation(deps.timber) 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/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt new file mode 100644 index 0000000000..1cc4d6c0b8 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.markets + +import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +class GetTokenMarketCryptoCurrency( + private val marketsTokenRepository: MarketsTokenRepository, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + tokenMarketParams: TokenMarketParams, + network: TokenMarketInfo.Network, + ): CryptoCurrency? { + return marketsTokenRepository.createCryptoCurrency( + userWalletId = userWalletId, + token = tokenMarketParams, + network = network, + ) + } +} \ No newline at end of file diff --git a/domain/models/build.gradle.kts b/domain/models/build.gradle.kts index fa609f56f6..f2d3d5a0cf 100644 --- a/domain/models/build.gradle.kts +++ b/domain/models/build.gradle.kts @@ -10,7 +10,6 @@ tasks.withType().configureEach { } dependencies { - api(projects.domain.visa.models) api(projects.domain.core) api(projects.core.utils) diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt index 032940c3ee..467675af70 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt @@ -13,6 +13,7 @@ data class TokenReceiveConfig( val receiveAddress: List, val tokenReceiveNotification: List = emptyList(), val asset: Asset = Asset.Currency, + val type: TokenReceiveType = TokenReceiveType.Default, ) @Serializable @@ -34,4 +35,25 @@ data class TokenReceiveNotification( enum class Asset { Currency, NFT +} + +@Serializable +sealed class TokenReceiveType { + + /** + * Default setting. + * TokenReceiveComponent will use [CryptoCurrency] to get token icon and name + */ + data object Default : TokenReceiveType() + + /** + * Custom setting. + * TokenReceiveComponent will use custom icon and name + */ + data class Custom( + val tokenIconUrl: String, + val tokenName: String, + val fallbackTint: Int, + val fallbackBackground: Int, + ) : TokenReceiveType() } \ No newline at end of file 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/CryptoCurrency.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrency.kt index 3e745dc152..31a759a3c0 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrency.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrency.kt @@ -108,6 +108,10 @@ sealed class CryptoCurrency { is Body.NetworkIdWithDerivationPath -> body.rawId } + /** Flag indicating whether the cryptocurrency is a coin */ + val isCoin: Boolean + get() = prefix == Prefix.COIN_PREFIX + /** * Represents the different types of prefixes that can be associated with a cryptocurrency ID. * These prefixes can help in quickly categorizing the type of cryptocurrency. @@ -131,16 +135,16 @@ sealed class CryptoCurrency { /** The value of the body. */ abstract val value: String + /** + * Represents a raw network ID. + * Should be used for cryptocurrencies that do not support a derivation path. + */ @Serializable - /** Represents a raw network ID. */ data class NetworkId(val rawId: String) : Body() { override val value: String get() = rawId } - /** - * Represents a raw network ID with a network derivation path. - * Should be used for a cryptocurrencies with custom derivation path. - */ + /** Represents a raw network ID with a network derivation path */ @Serializable data class NetworkIdWithDerivationPath( val rawId: String, @@ -227,9 +231,9 @@ sealed class CryptoCurrency { * Example: * 1. coin⟨BCH⟩bitcoin-cash * 2. coin⟨ETH→12367123⟩ethereum + * 3. token⟨ETH→12367123⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7 */ fun fromValue(value: String): ID { - // ID(value='coin⟨BCH⟩bitcoin-cash' val parts = value.split(PREFIX_DELIMITER, SUFFIX_DELIMITER) require(value = parts.size == ID_PARTS_COUNT) { "Invalid ID format: $value" } 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/models/src/main/kotlin/com/tangem/domain/models/scan/ScanResponse.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/scan/ScanResponse.kt index 44caa2dc29..1b545dc1bb 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/scan/ScanResponse.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/scan/ScanResponse.kt @@ -5,7 +5,6 @@ import com.squareup.moshi.JsonClass import com.tangem.common.card.WalletData import com.tangem.common.extensions.ByteArrayKey import com.tangem.domain.models.scan.serialization.ScanResponseAsStringSerializer -import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.operations.backup.PrimaryCard import com.tangem.operations.derivation.ExtendedPublicKeysMap import kotlinx.serialization.Serializable @@ -21,7 +20,7 @@ data class ScanResponse( @Json(name = "productType") val productType: ProductType, @Json(name = "walletData") val walletData: WalletData?, @Json(name = "secondTwinPublicKey") val secondTwinPublicKey: String? = null, - @Json(name = "visaCardActivationStatus") val visaCardActivationStatus: VisaCardActivationStatus? = null, + // @Json(name = "visaCardActivationStatus") val visaCardActivationStatus: VisaCardActivationStatus? = null, @Json(name = "derivedKeys") val derivedKeys: Map = mapOf(), @Json(name = "primaryCard") val primaryCard: PrimaryCard? = null, ) diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/scan/serialization/ScanResponseAsStringSerializer.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/scan/serialization/ScanResponseAsStringSerializer.kt index 62431daf4f..17a25655e1 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/scan/serialization/ScanResponseAsStringSerializer.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/scan/serialization/ScanResponseAsStringSerializer.kt @@ -4,8 +4,6 @@ import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.tangem.common.json.TangemSdkAdapter import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.visa.model.VisaActivationRemoteState -import com.tangem.domain.visa.model.VisaCardActivationStatus import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -24,8 +22,8 @@ internal object ScanResponseAsStringSerializer : KSerializer { .add(TangemSdkAdapter.DateAdapter()) .add(TangemSdkAdapter.DerivationNodeAdapter()) .add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model - .add(VisaActivationRemoteState.jsonAdapter) - .add(VisaCardActivationStatus.jsonAdapter) + // .add(VisaActivationRemoteState.jsonAdapter) + // .add(VisaCardActivationStatus.jsonAdapter) .addLast(KotlinJsonAdapterFactory()) .build() 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/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt index 0ad2638066..c1ed26365a 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.swap +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -59,9 +60,13 @@ interface SwapTransactionRepository { * * @param txId transaction id to update * @param status new transaction status - * @param refundTokenCurrency refund token + * @param accountWithCurrency account id with refund token */ - suspend fun storeTransactionState(txId: String, status: SwapStatusModel, refundTokenCurrency: CryptoCurrency?) + suspend fun storeTransactionState( + txId: String, + status: SwapStatusModel, + accountWithCurrency: Pair? = null, + ) /** * Save last swapped crypto currency token diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt index 59381c5e65..661f363200 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RemoveCurrencyUseCase.kt @@ -9,12 +9,14 @@ import com.tangem.domain.tokens.model.remove.RemoveCurrencyError import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade +@Deprecated("Use SaveCryptoCurrenciesUseCase") class RemoveCurrencyUseCase( private val currenciesRepository: CurrenciesRepository, private val walletManagersFacade: WalletManagersFacade, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, ) { + @Deprecated("Use SaveCryptoCurrenciesUseCase") suspend operator fun invoke( userWalletId: UserWalletId, currency: CryptoCurrency, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt index 19c51a1f07..41ba3d4e24 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt @@ -12,6 +12,7 @@ import com.tangem.domain.models.tokenlist.TokenList import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero import java.math.BigDecimal +import java.math.RoundingMode /** * Utility object for calculating the [PriceChange] of a cryptocurrency portfolio. @@ -50,11 +51,11 @@ object PriceChangeCalculator { } val total = statuses.sumOf { - val weight = it.value.fiatAmount.orZero().divide(balance) + val weight = it.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) val priceChange = it.value.priceChange.orZero() weight * priceChange - } + }.stripTrailingZeros() return PriceChange(value = total, source = walletTotalFiatBalance.source).lceContent() } 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/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt new file mode 100644 index 0000000000..e0b596f81e --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt @@ -0,0 +1,124 @@ +package com.tangem.domain.transaction.usecase + +import com.tangem.domain.models.Asset +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.TokenReceiveNotification +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase +import com.tangem.domain.transaction.R +import com.tangem.lib.crypto.BlockchainUtils + +class ReceiveAddressesFactory( + private val getEnsNameUseCase: GetEnsNameUseCase, + private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, +) { + + suspend fun create( + status: CryptoCurrencyStatus, + userWalletId: UserWalletId, + notifications: List = emptyList(), + ): TokenReceiveConfig? { + val addresses = status.value.networkAddress ?: return null + val cryptoCurrency = status.currency + + val ensName = getEnsNameUseCase.invoke( + userWalletId = userWalletId, + network = cryptoCurrency.network, + address = addresses.defaultAddress.value, + ) + + val receiveAddresses = buildList { + ensName?.let { ens -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Ens, + value = ens, + ), + ) + } + addresses.availableAddresses.map { address -> + add( + ReceiveAddressModel( + nameService = when (address.type) { + NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default + NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy + }, + value = address.value, + ), + ) + } + } + return TokenReceiveConfig( + shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(), + cryptoCurrency = cryptoCurrency, + userWalletId = userWalletId, + showMemoDisclaimer = cryptoCurrency.network.transactionExtrasType != Network + .TransactionExtrasType.NONE, + receiveAddress = receiveAddresses, + tokenReceiveNotification = notifications, + ) + } + + suspend fun createForNft( + userWalletId: UserWalletId, + addresses: NetworkAddress, + network: Network, + nft: CryptoCurrency, + ): TokenReceiveConfig { + val cryptoCurrency = nft + + val ensName = getEnsNameUseCase.invoke( + userWalletId = userWalletId, + network = network, + address = addresses.defaultAddress.value, + ) + + val receiveAddresses = buildList { + ensName?.let { ens -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Ens, + value = ens, + ), + ) + } + addresses.availableAddresses.map { address -> + add( + ReceiveAddressModel( + nameService = when (address.type) { + NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default + NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy + }, + value = address.value, + ), + ) + } + } + + val notifications = buildList { + if (BlockchainUtils.isSolana(network.rawId)) { + add( + TokenReceiveNotification( + title = R.string.nft_receive_unsupported_types, + subtitle = R.string.nft_receive_unsupported_types_description, + ), + ) + } + } + + return TokenReceiveConfig( + shouldShowWarning = Asset.NFT.name !in getViewedTokenReceiveWarningUseCase(), + cryptoCurrency = cryptoCurrency, + userWalletId = userWalletId, + showMemoDisclaimer = false, + receiveAddress = receiveAddresses, + tokenReceiveNotification = notifications, + asset = Asset.NFT, + ) + } +} \ No newline at end of file diff --git a/domain/visa/models/build.gradle.kts b/domain/visa/models/build.gradle.kts index 2de3acff50..e528d11260 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -12,4 +12,7 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.jodatime) implementation(projects.core.error) + + /** Domain models */ + implementation(projects.domain.models) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt new file mode 100644 index 0000000000..b42e77c698 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.pay + +import kotlinx.serialization.Serializable + +@Serializable +data class TangemPayDetailsConfig( + val customerWalletAddress: String, + val cardNumberEnd: String, + val chainId: Int, + val depositAddress: String?, +) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt index 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/DataForReceiveFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/DataForReceiveFactory.kt new file mode 100644 index 0000000000..1b78f76580 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/DataForReceiveFactory.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.pay + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +interface DataForReceiveFactory { + + fun getDataForReceive(depositAddress: String, chainId: Int): Either +} + +data class DataForReceive( + val walletId: UserWalletId, + val currency: CryptoCurrency, + val receiveAddress: List, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 9a5438f259..9937d2e1ff 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?, ) { @@ -25,7 +23,6 @@ data class CustomerInfo( val balance: BigDecimal, val currencyCode: String, val customerWalletAddress: String, + val depositAddress: 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/build.gradle.kts b/domain/yield-supply/build.gradle.kts index bc5d9b6904..b2648f08f0 100644 --- a/domain/yield-supply/build.gradle.kts +++ b/domain/yield-supply/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { implementation(projects.domain.legacy) implementation(projects.domain.blockaid.models) implementation(projects.domain.blockaid) + implementation(projects.domain.quotes) + implementation(projects.domain.tokens) /** Tandem SDK */ implementation(tangemDeps.blockchain) 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/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt new file mode 100644 index 0000000000..b544b7fe62 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyMinAmountUseCase.kt @@ -0,0 +1,88 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.transaction.FeeRepository +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +class YieldSupplyMinAmountUseCase( + private val feeRepository: FeeRepository, + private val quotesRepository: QuotesRepository, + private val currenciesRepository: CurrenciesRepository, +) { + + suspend operator fun invoke( + userWallet: UserWallet, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either = catch { + val feeWithoutGas = feeRepository.getEthereumFeeWithoutGas(userWallet, cryptoCurrencyStatus.currency) + + val fiatRate = cryptoCurrencyStatus.value.fiatRate ?: error("Fiat rate is missing") + require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" } + + val nativeCryptoCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = cryptoCurrencyStatus.currency.network.id, + derivationPath = cryptoCurrencyStatus.currency.network.derivationPath, + ) + + val quotes = + quotesRepository.getMultiQuoteSyncOrNull(setOfNotNull(nativeCryptoCurrency.id.rawCurrencyId)) + ?: error("Quotes for native coin are unavailable") + + val quotesStatus = quotes.firstOrNull() ?: error("Empty quotes list for native coin") + + val nativeFiatRate = (quotesStatus.value as? QuoteStatus.Data)?.fiatRate + ?: error("Native fiat rate is missing") + require(nativeFiatRate > BigDecimal.ZERO) { "Native fiat rate must be > 0" } + + val nativeGas = feeWithoutGas.fixFee(nativeCryptoCurrency, ETHEREUM_CONSTANT_GAS_LIMIT) + + val rateRatio = nativeFiatRate.divide( + fiatRate, + cryptoCurrencyStatus.currency.decimals, + RoundingMode.HALF_UP, + ) + + val tokenValue = rateRatio.multiply(nativeGas.amount.value) + + val feeBuffered = tokenValue.multiply(FEE_BUFFER_MULTIPLIER) + + feeBuffered + .divide(MAX_FEE_PERCENT, cryptoCurrencyStatus.currency.decimals, RoundingMode.HALF_UP) + .stripTrailingZeros() + } + + private fun Fee.fixFee(cryptoCurrency: CryptoCurrency, gasLimit: BigInteger) = when (this) { + is Fee.Ethereum.Legacy -> copy( + gasLimit = gasLimit, + amount = amount.copy( + value = gasPrice.multiply(gasLimit) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + is Fee.Ethereum.EIP1559 -> copy( + gasLimit = gasLimit, + amount = amount.copy( + value = maxFeePerGas.multiply(gasLimit) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + else -> this + } + + private companion object { + val FEE_BUFFER_MULTIPLIER: BigDecimal = BigDecimal("1.25") + val MAX_FEE_PERCENT: BigDecimal = BigDecimal("0.04") + val ETHEREUM_CONSTANT_GAS_LIMIT = 350_000.toBigInteger() + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt new file mode 100644 index 0000000000..c140d8f35e --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt @@ -0,0 +1,247 @@ +package com.tangem.domain.yield.supply + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +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.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +class YieldSupplyMinAmountUseCaseTest { + + private val feeRepository: FeeRepository = mockk(relaxed = true) + private val quotesRepository: QuotesRepository = mockk(relaxed = true) + private val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) + + private val useCase = YieldSupplyMinAmountUseCase( + feeRepository = feeRepository, + quotesRepository = quotesRepository, + currenciesRepository = currenciesRepository, + ) + + @Test + fun `GIVEN valid inputs WHEN invoke THEN return expected min amount`() = runTest { + val network = createNetwork() + val nativeCoin = createNativeCoin(network) + val token = createToken(network) + + val tokenFiatRate = BigDecimal("1.00") + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Loaded( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = tokenFiatRate, + priceChange = BigDecimal.ZERO, + yieldBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + val userWallet = createUserWallet() + val maxFeePerGas = BigInteger("158320679232") + val fee = createEip1559Fee(maxFeePerGas) + + coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet, token) } returns fee + coEvery { + currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + } returns nativeCoin + + val nativeFiatRate = BigDecimal("0.20353756561552608") + coEvery { + quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) + } returns setOf( + QuoteStatus( + rawCurrencyId = CryptoCurrency.RawID("polygon-ecosystem-token"), + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = nativeFiatRate, + priceChange = BigDecimal("0.09000000000000007"), + ), + ), + ) + val expected = expectedMinAmount(maxFeePerGas, nativeFiatRate, tokenFiatRate, token.decimals) + val result = useCase(userWallet, tokenStatus).getOrNull() + Truth.assertThat(result).isEqualTo(expected) + } + + @Test + fun `GIVEN missing token fiat rate WHEN invoke THEN return left with error`() = runTest { + val network = createNetwork() + val token = createToken(network) + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = null, + fiatRate = null, + priceChange = null, + yieldBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + val userWallet = createUserWallet() + val result = useCase(userWallet, tokenStatus) + Truth.assertThat(result.isLeft()).isTrue() + Truth.assertThat(result.leftOrNull()?.message).isEqualTo("Fiat rate is missing") + } + + @Test + fun `GIVEN quotes unavailable WHEN invoke THEN return left with error`() = runTest { + val network = createNetwork() + val nativeCoin = createNativeCoin(network) + val token = createToken(network) + val tokenStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + yieldBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + val userWallet = createUserWallet() + val maxFeePerGas = BigInteger("158320679232") + val fee = createEip1559Fee(maxFeePerGas) + + coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet, token) } returns fee + coEvery { + currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + } returns nativeCoin + + coEvery { + quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) + } returns null + val result = useCase(userWallet, tokenStatus) + Truth.assertThat(result.isLeft()).isTrue() + Truth.assertThat(result.leftOrNull()?.message).isEqualTo("Quotes for native coin are unavailable") + } + + private fun createNetwork(): Network { + val derivationPath = Network.DerivationPath.None + return Network( + id = Network.ID(Network.RawID("polygon"), derivationPath), + backendId = "polygon", + name = "Polygon", + currencySymbol = "MATIC", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.ENS, + ) + } + + private fun createNativeCoin(network: Network): CryptoCurrency.Coin { + val nativeCoinId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(network.rawId), + suffix = CryptoCurrency.ID.Suffix.RawID("polygon-ecosystem-token"), + ) + return CryptoCurrency.Coin( + id = nativeCoinId, + network = network, + name = "Polygon", + symbol = "MATIC", + decimals = 18, + iconUrl = null, + isCustom = false, + ) + } + + private fun createToken(network: Network): CryptoCurrency.Token { + val tokenId = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(network.rawId), + suffix = CryptoCurrency.ID.Suffix.RawID("test-token", "0xContract"), + ) + return CryptoCurrency.Token( + id = tokenId, + network = network, + name = "Test Token", + symbol = "TT", + decimals = 18, + iconUrl = null, + isCustom = false, + contractAddress = "0xContract", + ) + } + + private fun createUserWallet(): UserWallet { + val wallet = mockk(relaxed = true) + every { wallet.walletId } returns UserWalletId("001122") + return wallet + } + + private fun createEip1559Fee(maxFeePerGas: BigInteger): Fee.Ethereum.EIP1559 { + return Fee.Ethereum.EIP1559( + amount = Amount(value = BigDecimal.ZERO, blockchain = Blockchain.Ethereum), + gasLimit = BigInteger.ZERO, + maxFeePerGas = maxFeePerGas, + priorityFee = BigInteger.ZERO, + ) + } + + private fun expectedMinAmount( + maxFeePerGas: BigInteger, + nativeFiatRate: BigDecimal, + tokenFiatRate: BigDecimal, + decimals: Int, + ): BigDecimal { + val gasLimit = BigInteger("350000") + val nativeGas = maxFeePerGas.multiply(gasLimit).toBigDecimal().movePointLeft(decimals) + val rateRatio = nativeFiatRate.divide(tokenFiatRate, decimals, RoundingMode.HALF_UP) + val tokenValue = rateRatio.multiply(nativeGas) + val feeBuffered = tokenValue.multiply(BigDecimal("1.25")) + return feeBuffered + .divide(BigDecimal("0.04"), decimals, RoundingMode.HALF_UP) + .stripTrailingZeros() + } +} \ 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..f09cafe966 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 @@ -2,15 +2,15 @@ package com.tangem.features.account import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.core.lce.Lce -import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokens.error.TokenListError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +/** + * How to use see [PortfolioSelectorComponent] + */ interface PortfolioFetcher { val data: Flow @@ -25,10 +25,11 @@ interface PortfolioFetcher { ) data class PortfolioBalance( - val walletBalance: Lce, + val userWallet: UserWallet, val accountsBalance: AccountStatusList, ) { - val userWallet: UserWallet get() = accountsBalance.userWallet + val walletBalance get() = accountsBalance.totalFiatBalance + val userWalletId: UserWalletId get() = userWallet.walletId } sealed interface Mode { @@ -36,6 +37,9 @@ interface PortfolioFetcher { data class Wallet(val walletId: UserWalletId) : Mode } + /** + * @param[mode] supports runtime change [PortfolioFetcher.updateMode] + */ interface Factory { fun create(mode: Mode, scope: CoroutineScope): PortfolioFetcher } diff --git a/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt b/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt index 7216cd8ad7..0b98d6b218 100644 --- a/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt +++ b/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt @@ -8,27 +8,55 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.wallet.UserWallet import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +/** + * How to use + * 1) Create and keep instance of [PortfolioFetcher] and [PortfolioSelectorController] in your Feature Model + * 2) Provide them via [Params] + * 3) Now you have a bridge between your Feature and PortfolioSelector + * 4) Initial state is unselected. Select yourself [PortfolioSelectorController.selectAccount] + * or offer users to select + * + * 5) Listen [PortfolioSelectorController.selectedAccount] or [PortfolioSelectorController.selectedAccountWithData] + * + * Note: + * - Supports [PortfolioSelectorComponent.BottomSheet] and [PortfolioSelectorComponent.Content] modes + */ interface PortfolioSelectorComponent : ComposableBottomSheetComponent, ComposableContentComponent { val title: StateFlow data class Params( - val onDismiss: () -> Unit, val portfolioFetcher: PortfolioFetcher, val controller: PortfolioSelectorController, + val bsCallback: BottomSheetCallback? = null, ) + interface BottomSheetCallback { + val onDismiss: () -> Unit + val onBack: () -> Unit + } + interface Factory : ComponentFactory } /** + * How to use see [PortfolioSelectorComponent] + * * if [isAccountMode] is false it's mean [selectedAccount] emit [AccountId] for Main account */ interface PortfolioSelectorController { val isAccountMode: Flow - val selectedAccount: StateFlow + val selectedAccount: Flow + val selectedAccountSync: AccountId? + + /** + * for some Feature specific filtering + * combine and update with your Feature data and [PortfolioFetcher.data] + */ + val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean> fun selectAccount(accountId: AccountId?) fun selectedAccountWithData(portfolioFetcher: PortfolioFetcher): Flow?> 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/archived/di/AccountArchivedModule.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/di/AccountArchivedModule.kt index 21c674cef2..a853d04fe1 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/di/AccountArchivedModule.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/di/AccountArchivedModule.kt @@ -1,25 +1,18 @@ package com.tangem.features.account.archived.di +import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.archived.ArchivedAccountListModel -import com.tangem.features.account.archived.DefaultArchivedAccountListComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap @Module -@InstallIn(SingletonComponent::class) +@InstallIn(ModelComponent::class) internal interface AccountArchivedModule { - @Binds - fun bindArchivedAccountListComponentFactory( - impl: DefaultArchivedAccountListComponent.Factory, - ): ArchivedAccountListComponent.Factory - @Binds @IntoMap @ClassKey(ArchivedAccountListModel::class) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/di/AccountCreateEditModule.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/di/AccountCreateEditModule.kt index ab899b3f5c..3c85af74ee 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/di/AccountCreateEditModule.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/di/AccountCreateEditModule.kt @@ -1,25 +1,18 @@ package com.tangem.features.account.createedit.di +import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.createedit.AccountCreateEditModel -import com.tangem.features.account.createedit.DefaultAccountCreateEditComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap @Module -@InstallIn(SingletonComponent::class) +@InstallIn(ModelComponent::class) internal interface AccountCreateEditModule { - @Binds - fun bindAccountCreateEditComponentFactory( - impl: DefaultAccountCreateEditComponent.Factory, - ): AccountCreateEditComponent.Factory - @Binds @IntoMap @ClassKey(AccountCreateEditModel::class) 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/di/AccountDetailsModule.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/di/AccountDetailsModule.kt index fdc4feda8a..7975787c3e 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/di/AccountDetailsModule.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/di/AccountDetailsModule.kt @@ -1,25 +1,18 @@ package com.tangem.features.account.details.di +import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.details.AccountDetailsModel -import com.tangem.features.account.details.DefaultAccountDetailsComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap @Module -@InstallIn(SingletonComponent::class) +@InstallIn(ModelComponent::class) internal interface AccountDetailsModule { - @Binds - fun bindAccountDetailsComponentFactory( - impl: DefaultAccountDetailsComponent.Factory, - ): AccountDetailsComponent.Factory - @Binds @IntoMap @ClassKey(AccountDetailsModel::class) 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 pairs.toMap() } } - private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow> = combine( - flow = accountStatusListFlow(wallet), - flow2 = getWalletTotalBalanceUseCase(wallet.walletId), - transform = { accountStatusList, walletBalance -> - val portfolioBalance = PortfolioBalance(walletBalance, accountStatusList) - wallet to portfolioBalance - }, - ) + private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow> = + accountStatusListFlow(wallet).map { wallet to PortfolioBalance(wallet, it) } private fun accountStatusListFlow(wallet: UserWallet): Flow = singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(wallet.walletId)) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt index 7a67a32392..2f0229f8f3 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt @@ -31,13 +31,13 @@ internal class DefaultPortfolioSelectorComponent @AssistedInject constructor( .stateIn(componentScope, SharingStarted.Lazily, model.state.value.title) override fun dismiss() { - params.onDismiss() + params.bsCallback?.onDismiss() } @Composable override fun BottomSheet() { val state by model.state.collectAsStateWithLifecycle() - PortfolioSelectorBS(state, onDismiss = ::dismiss) + PortfolioSelectorBS(state = state, onDismiss = ::dismiss, onBack = { params.bsCallback?.onBack() }) } @Composable diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt index f607006e40..1bd36673a5 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt @@ -4,23 +4,33 @@ import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.account.PortfolioSelectorController import com.tangem.features.account.PortfolioFetcher -import kotlinx.coroutines.flow.* +import com.tangem.features.account.PortfolioSelectorController +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import javax.inject.Inject internal class DefaultPortfolioSelectorController @Inject constructor( private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, ) : PortfolioSelectorController { - private val _selectedAccount: MutableStateFlow = MutableStateFlow(null) + private val _selectedAccount: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) override val isAccountMode: Flow by lazy { isAccountsModeEnabledUseCase() } + // without StateFlow and distinctUntilChanged to allow reselect and correct navigation + override val selectedAccount: Flow get() = _selectedAccount + override val selectedAccountSync: AccountId? get() = _selectedAccount.replayCache.firstOrNull() - override val selectedAccount: StateFlow get() = _selectedAccount + override val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean> = MutableStateFlow { _, _ -> true } override fun selectAccount(accountId: AccountId?) { - _selectedAccount.update { accountId } + _selectedAccount.tryEmit(accountId) } override fun selectedAccountWithData(portfolioFetcher: PortfolioFetcher): Flow?> = diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt index bef9142229..0472196bdb 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt @@ -23,7 +23,6 @@ import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -45,9 +44,11 @@ internal class PortfolioSelectorModel @Inject constructor( init { combine( flow = isAccountsModeEnabledUseCase(), - flow2 = loadBalanceWithArtwork(), - transform = { isAccountsMode, (portfolioData, artworks) -> - val uiList = buildUiList(isAccountsMode, portfolioData, artworks) + flow2 = balanceFetcher.data, + flow3 = walletImageFetcher.allWallets(ArtworkSize.SMALL), + flow4 = selectorController.isEnabled, + transform = { isAccountsMode, portfolioData, artworks, isEnabled -> + val uiList = buildUiList(isAccountsMode, portfolioData, artworks, isEnabled) val title = when (isAccountsMode) { true -> resourceReference(R.string.common_choose_account) false -> resourceReference(R.string.common_choose_wallet) @@ -66,24 +67,25 @@ internal class PortfolioSelectorModel @Inject constructor( isAccountsMode: Boolean, portfolioData: PortfolioFetcher.Data, artworks: Map, + isEnabled: (UserWallet, AccountStatus) -> Boolean, ): List = when (isAccountsMode) { - true -> buildAccountsList(portfolioData, artworks) - false -> buildWalletList(portfolioData, artworks) + true -> buildAccountsList(portfolioData, artworks, isEnabled) + false -> buildWalletList(portfolioData, artworks, isEnabled) } private fun buildWalletList( portfolioData: PortfolioFetcher.Data, artworks: Map, + isEnabled: (UserWallet, AccountStatus) -> Boolean, ): List = buildList { val appCurrency = portfolioData.appCurrency val isBalanceHidden = portfolioData.isBalanceHidden val lockedWallets = mutableListOf() portfolioData.balances.forEach { wallet, portfolio -> - val balance = portfolio.walletBalance.getOrNull() + val balance = portfolio.walletBalance val walletItemUM = UserWalletItemUMConverter( onClick = { - // todo account - // selectorController.selectAccount(portfolio.accountsBalance.mainAccount) + selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId) }, appCurrency = appCurrency, balance = balance, @@ -92,7 +94,10 @@ internal class PortfolioSelectorModel @Inject constructor( isAuthMode = false, ).convert(wallet) if (walletItemUM.isEnabled) { - add(PortfolioSelectorItemUM.Portfolio(walletItemUM)) + val isEnabledByFeature = isEnabled(wallet, portfolio.accountsBalance.mainAccount) + val finalWalletItemUM = + if (isEnabledByFeature) walletItemUM else walletItemUM.copy(isEnabled = false) + add(PortfolioSelectorItemUM.Portfolio(finalWalletItemUM)) } else { lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM)) } @@ -110,16 +115,16 @@ internal class PortfolioSelectorModel @Inject constructor( private fun buildAccountsList( portfolioData: PortfolioFetcher.Data, artworks: Map, + isEnabled: (UserWallet, AccountStatus) -> Boolean, ): List = buildList { val appCurrency = portfolioData.appCurrency val isBalanceHidden = portfolioData.isBalanceHidden val lockedWallets = mutableListOf() portfolioData.balances.forEach { wallet, portfolio -> - val balance = portfolio.walletBalance.getOrNull() + val balance = portfolio.walletBalance val walletItemUM = UserWalletItemUMConverter( onClick = { - // todo account - // selectorController.selectAccount(portfolio.accountsBalance.mainAccount) + selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId) }, appCurrency = appCurrency, balance = balance, @@ -138,8 +143,8 @@ internal class PortfolioSelectorModel @Inject constructor( ) add(walletTitle) - add(PortfolioSelectorItemUM.Portfolio(walletItemUM)) portfolio.accountsBalance.accountStatuses.forEach { accountStatus -> + val isEnabledByFeature = isEnabled(wallet, accountStatus) val account = accountStatus.account val accountBalance = when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance @@ -148,6 +153,7 @@ internal class PortfolioSelectorModel @Inject constructor( onClick = { selectorController.selectAccount(account.accountId) }, appCurrency = appCurrency, accountBalance = accountBalance, + isEnabled = isEnabledByFeature, isBalanceHidden = isBalanceHidden, ).convert(account) add(PortfolioSelectorItemUM.Portfolio(accountItemUM)) @@ -163,22 +169,6 @@ internal class PortfolioSelectorModel @Inject constructor( } } - private fun loadBalanceWithArtwork(): - Flow>> { - val wallets = Channel>() - val portfolioFlow = balanceFetcher.data - .onEach { wallets.trySend(it.balances.keys) } - - val artworksFlow = wallets.receiveAsFlow() - .distinctUntilChanged() - .flatMapLatest { walletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) } - - return combine( - flow = portfolioFlow, - flow2 = artworksFlow, - ) { portfolioData, artworks -> portfolioData to artworks } - } - private fun emptyState() = PortfolioSelectorUM( items = persistentListOf(), title = TextReference.EMPTY, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt index 9044bd9845..f33617879f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt @@ -19,21 +19,26 @@ import com.tangem.features.account.impl.R import com.tangem.features.account.selector.entity.PortfolioSelectorUM @Composable -internal fun PortfolioSelectorBS(state: PortfolioSelectorUM, onDismiss: () -> Unit, modifier: Modifier = Modifier) { +internal fun PortfolioSelectorBS( + state: PortfolioSelectorUM, + onDismiss: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { TangemModalBottomSheet( config = TangemBottomSheetConfig( isShown = true, onDismissRequest = onDismiss, content = TangemBottomSheetConfigContent.Empty, ), - onBack = onDismiss, + onBack = onBack, scrollableContent = false, - containerColor = TangemTheme.colors.background.secondary, + containerColor = TangemTheme.colors.background.tertiary, title = { TangemModalBottomSheetTitle( title = state.title, startIconRes = R.drawable.ic_back_24, - onStartClick = onDismiss, + onStartClick = onBack, ) }, content = { @@ -54,7 +59,8 @@ private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::cla PortfolioSelectorBS( state = params, onDismiss = {}, - modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + modifier = Modifier, + onBack = {}, ) } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt index 2bf56e09c3..918832e89b 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt @@ -2,8 +2,10 @@ package com.tangem.features.account.selector.ui import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -18,9 +20,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.common.ui.account.AccountIconPreviewData -import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.common.ui.userwallet.UserWalletItemRow import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -66,12 +69,16 @@ internal fun PortfolioSelectorContent( } when (item) { - is PortfolioSelectorItemUM.Portfolio -> UserWalletItem( + is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow( state = item.item, modifier = offsetModifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size68) .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) - .background(color = TangemTheme.colors.background.primary) - .let { if (!item.item.isEnabled) it.alpha(DISABLED_WALLET_ALPHA) else it }, + .background(TangemTheme.colors.background.action) + .clickable(enabled = item.item.isEnabled, onClick = item.item.onClick) + .padding(all = TangemTheme.dimens.spacing12) + .conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) }, ) is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow( model = item, @@ -103,7 +110,7 @@ private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::cla TangemThemePreview { PortfolioSelectorContent( state = params, - modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + modifier = Modifier.background(color = TangemTheme.colors.background.tertiary), ) } } @@ -125,9 +132,11 @@ internal object PortfolioSelectorPreviewData { name = accountName, icon = AccountIconPreviewData.randomAccountIcon(), ), - label = null, ) + private val lockedAccountItem: UserWalletItemUM + get() = accountItem.copy(isEnabled = false) + private val walletItem: UserWalletItemUM get() = UserWalletItemUM( id = UserWalletId(UUID.randomUUID().toString().encodeToByteArray()), @@ -137,7 +146,6 @@ internal object PortfolioSelectorPreviewData { isEnabled = true, onClick = { }, imageState = ImageState.MobileWallet, - label = null, ) private val lockedWalletItem: UserWalletItemUM @@ -152,7 +160,7 @@ internal object PortfolioSelectorPreviewData { accountItem .let { PortfolioSelectorItemUM.Portfolio(it) } .let(::add) - accountItem + lockedAccountItem .let { PortfolioSelectorItemUM.Portfolio(it) } .let(::add) PortfolioSelectorItemUM.GroupTitle( diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 1225bd8144..20b64f77ca 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -1,72 +1,80 @@ package com.tangem.features.createwalletselection -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.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.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.DialogMessage -import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.analytics.IntroductionProcess -import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop -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.createwalletselection.entity.CreateWalletSelectionUM +import com.tangem.features.createwalletselection.impl.R 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 CreateWalletSelectionModel @Inject constructor( 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 generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, - private val userWalletsListRepository: UserWalletsListRepository, - @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { internal val uiState: StateFlow 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..c0051682a8 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -0,0 +1,244 @@ +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 = scanResponse, isImported = userWallet.isImported) + appRouter.replaceAll(AppRoute.Wallet) + }, + ) + } + + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) { + val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) + if (currency != null) { + analyticsEventHandler.send( + SignedIn( + currency = currency, + batch = scanResponse.card.batchId, + signInType = SignInType.Card, + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), + isImported = isImported, + hasBackup = scanResponse.card.backupStatus?.isActive, + ), + ) + } + } + + private fun setLoading(isLoading: Boolean) { + uiState.update { it.copy(isScanInProgress = isLoading) } + } + + private fun handleScanError(error: TangemError) { + when (error) { + is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable() + is TangemSdkError -> Timber.e(error, "Scan error occurred") + else -> Timber.e(error, "Error happened") + } + } + + private fun handleNfcFeatureUnavailable() { + uiMessageSender.send( + message = DialogMessage( + message = resourceReference(R.string.nfc_error_unavailable), + title = resourceReference(id = R.string.common_error), + ), + ) + } +} \ No newline at end of file diff --git a/features/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..f82b96124a 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() { @@ -207,13 +202,13 @@ internal class HomeModel @Inject constructor( ifRight = { reduxStateHolder.onUserWalletSelected(userWallet) setLoading(false) - sendSignedInCardAnalyticsEvent(scanResponse) + sendSignedInCardAnalyticsEvent(scanResponse, userWallet.isImported) appRouter.replaceAll(AppRoute.Wallet) }, ) } - private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) { val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) if (currency != null) { analyticsEventHandler.send( @@ -222,6 +217,7 @@ internal class HomeModel @Inject constructor( batch = scanResponse.card.batchId, signInType = SignInType.Card, walletsCount = getWalletsCount().toString(), + isImported = isImported, hasBackup = scanResponse.card.backupStatus?.isActive, ), ) @@ -240,7 +236,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 availableToAdd: Boolean + get() = availableToAddWallets.isNotEmpty() + val isSinglePortfolio: Boolean + get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1 +} + +internal data class AvailableToAddWallet( + val userWallet: UserWallet, + val accounts: Set, + val availableNetworks: Set, + val availableToAddAccounts: Map, +) + +internal data class AvailableToAddAccount( + val account: AccountStatus, + val availableNetworks: Set, + val addedNetworks: Set, +) { + val isSingleNetwork: Boolean + get() = availableNetworks.size == 1 + + val availableToAddNetworks: Set = availableNetworks + .filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } } + .toSet() + + val addedMarketNetworks: Set = availableNetworks + .filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } } + .toSet() +} + +internal data class SelectedPortfolio( + val userWallet: UserWallet, + val account: AvailableToAddAccount, + val isAccountMode: Boolean, + val availableMorePortfolio: Boolean, +) + +internal data class SelectedNetwork( + val selectedNetwork: TokenMarketInfo.Network, + val cryptoCurrency: CryptoCurrency, + val availableMoreNetwork: Boolean, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/AddTokenComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/AddTokenComponent.kt new file mode 100644 index 0000000000..0b1272cba8 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/AddTokenComponent.kt @@ -0,0 +1,58 @@ +package com.tangem.features.markets.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.markets.portfolio.add.api.SelectedNetwork +import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio +import com.tangem.features.markets.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.markets.portfolio.add.impl.ui.AddTokenContent +import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class AddTokenComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: AddTokenModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val um = state.value ?: return + AddTokenContent( + modifier = modifier, + state = um, + ) + } + + data class Params( + val marketParams: TokenMarketParams, + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val selectedPortfolio: Flow, + val selectedNetwork: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onChangeNetworkClick() + fun onChangePortfolioClick() + fun onTokenAdded(status: CryptoCurrencyStatus) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): AddTokenComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt new file mode 100644 index 0000000000..3ab91ac88c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt @@ -0,0 +1,57 @@ +package com.tangem.features.markets.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.portfolio.add.impl.ui.ChooseNetworkContent +import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.markets.portfolio.impl.model.BlockchainRowUMConverter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toPersistentList + +internal class ChooseNetworkComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val state by lazy { + val converter = BlockchainRowUMConverter( + alreadyAddedNetworks = params.alreadyAdded.mapTo(mutableSetOf()) { it.networkId }, + ) + val allAvailableNetworks = params.allAvailable.map { it to true } + ChooseNetworkUM( + networks = converter.convertList(allAvailableNetworks).toPersistentList(), + onNetworkClick = onNetworkClick@{ row -> + val network = params.allAvailable + .find { it.networkId == row.id } + ?: return@onNetworkClick + params.callbacks.onNetworkSelected(network) + }, + ) + } + + @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/TokenActionsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/TokenActionsComponent.kt new file mode 100644 index 0000000000..93bf26a76f --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/TokenActionsComponent.kt @@ -0,0 +1,79 @@ +package com.tangem.features.markets.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.features.markets.portfolio.add.impl.model.TokenActionsModel +import com.tangem.features.markets.portfolio.add.impl.ui.TokenActionsContent +import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class TokenActionsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: TokenActionsModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TokenReceiveConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + val tokenActionsUM = state.value ?: return + TokenActionsContent( + modifier = modifier, + state = tokenActionsUM, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: TokenReceiveConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + + data class Params( + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val data: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onLaterClick() + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): TokenActionsComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt new file mode 100644 index 0000000000..132a4b8ba2 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -0,0 +1,90 @@ +package com.tangem.features.markets.portfolio.add.impl.converter + +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.markets.portfolio.add.api.AvailableToAddAccount +import com.tangem.features.markets.portfolio.add.api.AvailableToAddData +import com.tangem.features.markets.portfolio.add.api.AvailableToAddWallet +import javax.inject.Inject + +internal class AvailableToAddDataConverter @Inject constructor( + private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) { + + suspend fun convert( + balances: Map, + availableNetworks: Set, + marketParams: TokenMarketParams, + ): AvailableToAddData { + suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount { + val addedNetworks = availableNetworks + .mapNotNull { createCryptoCurrency(wallet, it, marketParams) } + .mapNotNull { getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, it) } + .mapNotNull { it.getOrNull()?.status?.currency?.network } + .toSet() + return AvailableToAddAccount( + account = this, + availableNetworks = availableNetworks, + addedNetworks = addedNetworks, + ) + } + + suspend fun getAvailableToAddWallet( + entry: Map.Entry, + ): AvailableToAddWallet { + val (wallet, balance) = entry + val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks) + val accounts = balance.accountsBalance.accountStatuses + val availableToAddAccounts: Map = accounts + .map { it.account.accountId to it.getAvailableToAddAccount(wallet) } + .filter { (_, account) -> account.availableToAddNetworks.isNotEmpty() } + .toMap() + return AvailableToAddWallet( + userWallet = wallet, + accounts = accounts, + availableNetworks = filteredNetworks, + availableToAddAccounts = availableToAddAccounts, + ) + } + + val availableToAddWallets: Map = balances + .map { + val (wallet, balance) = it + val availableToAddWallet = getAvailableToAddWallet(it) + wallet.walletId to availableToAddWallet + } + .filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() } + .toMap() + + return AvailableToAddData( + availableToAddWallets = availableToAddWallets, + ) + } + + private fun UserWallet.filteredAvailableNetworks(networks: Set) = + filterAvailableNetworksForWalletUseCase( + userWalletId = this.walletId, + networks = networks, + ) + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + marketParams: TokenMarketParams, + ): CryptoCurrency? = getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = marketParams, + network = network, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt new file mode 100644 index 0000000000..8e227d1ec8 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt @@ -0,0 +1,92 @@ +package com.tangem.features.markets.portfolio.add.impl.model + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.markets.portfolio.add.api.SelectedNetwork +import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio +import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent +import com.tangem.features.markets.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class AddTokenModel @Inject constructor( + paramsContainer: ParamsContainer, + private val uiBuilder: AddTokenUiBuilder, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder = params.eventBuilder + private val addTokenJob = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow(value = null) + + init { + combine( + flow = params.selectedNetwork.distinctUntilChanged(), + flow2 = params.selectedPortfolio.distinctUntilChanged(), + transform = { selectedNetwork, selectedPortfolio -> + addTokenJob.cancel() + val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio) + uiBuilder.updateContent( + selectedPortfolio = selectedPortfolio, + selectedNetwork = selectedNetwork, + isTangemIconVisible = isTangemIconVisible, + onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) }, + ) + }, + ) + .onEach { newUI -> uiState.value = newUI } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) = + modelScope.launch(dispatchers.default) { + val um = uiState.value ?: return@launch + uiState.value = um.toggleProgress(true) + val blockchainNames = listOf(selectedNetwork.selectedNetwork) + .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } + analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) + val cryptoCurrency = selectedNetwork.cryptoCurrency + val accountId = selectedPortfolio.account.account.account.accountId + saveCryptoCurrenciesUseCase( + accountId = accountId, + add = listOf(cryptoCurrency), + remove = listOf(), + ) + val status = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = accountId.userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ).getOrNull() ?: return@launch + params.callbacks.onTokenAdded(status.status) + uiState.value = um.toggleProgress(false) + } + + private suspend fun needColdWalletInteraction( + selectedNetwork: SelectedNetwork, + selectedPortfolio: SelectedPortfolio, + ): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = selectedPortfolio.userWallet.walletId, + networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt new file mode 100644 index 0000000000..d0823406f3 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -0,0 +1,103 @@ +package com.tangem.features.markets.portfolio.add.impl.model + +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.iconResId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.add.api.SelectedNetwork +import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio +import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent +import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM +import javax.inject.Inject + +@ModelScoped +internal class AddTokenUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, +) { + private val params = paramsContainer.require() + + private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network { + return AddTokenUM.Network( + icon = selectedNetwork.cryptoCurrency.network.iconResId, + name = stringReference(selectedNetwork.cryptoCurrency.network.name), + editable = selectedNetwork.availableMoreNetwork, + onClick = { params.callbacks.onChangeNetworkClick() }, + ) + } + + private fun createPortfolio(selectedPortfolio: SelectedPortfolio): AddTokenUM.Portfolio { + val accountIcon: CryptoPortfolioIconUM? + val portfolioName: TextReference + when (selectedPortfolio.isAccountMode) { + false -> { + accountIcon = null + portfolioName = stringReference(selectedPortfolio.userWallet.name) + } + true -> { + val accountStatus = selectedPortfolio.account.account + portfolioName = accountStatus.account.accountName.toUM().value + accountIcon = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM() + } + } + } + return AddTokenUM.Portfolio( + accountIconUM = accountIcon, + name = portfolioName, + editable = selectedPortfolio.availableMorePortfolio, + onClick = { params.callbacks.onChangePortfolioClick() }, + ) + } + + fun updateContent( + selectedPortfolio: SelectedPortfolio, + selectedNetwork: SelectedNetwork, + isTangemIconVisible: Boolean, + onConfirmClick: () -> Unit, + ): AddTokenUM { + // its may happens when change portfolio after selected both params in line navigation + val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks + .any { selectedNetwork.selectedNetwork.networkId == it.networkId } + val button = AddTokenUM.Button( + isEnabled = isAvailableNetwork, + showProgress = false, + isTangemIconVisible = isTangemIconVisible, + text = resourceReference(R.string.common_add), + onConfirmClick = onConfirmClick, + ) + val networkUM = createNetwork(selectedNetwork) + val portfolioUM = createPortfolio(selectedPortfolio) + val currency = selectedNetwork.cryptoCurrency + val tokenToAdd = TokenItemState.Content( + id = currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(currency), + titleState = TokenItemState.TitleState.Content(stringReference(currency.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return AddTokenUM( + tokenToAdd = tokenToAdd, + network = networkUM, + portfolio = portfolioUM, + button = button, + ) + } + + companion object { + + fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy( + button = this.button.copy(showProgress = showProgress), + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/TokenActionsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/TokenActionsModel.kt new file mode 100644 index 0000000000..a2d0ceeba2 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/TokenActionsModel.kt @@ -0,0 +1,80 @@ +package com.tangem.features.markets.portfolio.add.impl.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory +import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler +import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler.HandledQuickAction +import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class TokenActionsModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val uiBuilder: TokenActionsUiBuilder, + private val analyticsEventHandler: AnalyticsEventHandler, + private val receiveAddressesFactory: ReceiveAddressesFactory, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder get() = params.eventBuilder + private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val tokenActionsHandler: TokenActionsHandler = + tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { }, + onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) }, + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val uiState: StateFlow = params.data + .mapLatest { uiBuilder.build(it, tokenActionsHandler) } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + private fun handledQuickAction(handledAction: HandledQuickAction) { + val event = analyticsEventBuilder.quickActionClick( + actionUM = handledAction.action, + blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name, + ) + analyticsEventHandler.send(event) + val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive + if (!isReceive) return + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = handledAction.cryptoCurrencyData.status, + userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(tokenConfig) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/TokenActionsUiBuilder.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/TokenActionsUiBuilder.kt new file mode 100644 index 0000000000..be2f06ccdf --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/TokenActionsUiBuilder.kt @@ -0,0 +1,39 @@ +package com.tangem.features.markets.portfolio.add.impl.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.model.PortfolioTokenUMConverter +import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler +import javax.inject.Inject + +@ModelScoped +internal class TokenActionsUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, +) { + private val params = paramsContainer.require() + + fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { + val status = data.status + val tokenUM = TokenItemState.Content( + id = status.currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), + titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return TokenActionsUM( + token = tokenUM, + onLaterClick = { params.callbacks.onLaterClick() }, + quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler), + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt new file mode 100644 index 0000000000..02464553bd --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt @@ -0,0 +1,281 @@ +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.* +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.ui.Alignment +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +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.AccountIcon +import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags +import com.tangem.domain.models.account.AccountName +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM +import java.util.UUID + +@Composable +internal fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenItem( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = state.tokenToAdd, + isBalanceHidden = false, + ) + + SpacerH(TangemTheme.dimens.spacing14) + Column( + modifier = Modifier.background( + color = TangemTheme.colors.background.action, + shape = RoundedCornerShape(TangemTheme.dimens.radius14), + ), + ) { + PortfolioRow(state.portfolio) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + thickness = TangemTheme.dimens.size0_5, + color = TangemTheme.colors.stroke.primary, + ) + NetworkRow(state.network) + } + + SpacerH16() + + AddButton( + modifier = modifier.fillMaxWidth(), + state = state.button, + ) + } +} + +@Composable +private fun PortfolioRow(state: AddTokenUM.Portfolio, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clickable(enabled = state.editable, onClick = state.onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(leftText), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerW12() + if (state.accountIconUM != null) { + AccountIcon( + name = state.name, + icon = state.accountIconUM, + size = AccountIconSize.Small, + ) + } + Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp), + text = state.name.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (state.editable) { + Icon( + modifier = Modifier + .size(width = 18.dp, height = 24.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON), + painter = painterResource(id = R.drawable.ic_select_18_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } + } +} + +@Composable +private fun NetworkRow(state: AddTokenUM.Network, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clickable(enabled = state.editable, onClick = state.onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(R.string.wc_common_networks), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerW12() + Icon( + modifier = Modifier.size(24.dp), + tint = Color.Unspecified, + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + ) + Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp), + text = state.name.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (state.editable) { + Icon( + modifier = Modifier + .size(width = 18.dp, height = 24.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON), + painter = painterResource(id = R.drawable.ic_select_18_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } + } +} + +@Composable +private fun AddButton(state: AddTokenUM.Button, modifier: Modifier = Modifier) { + val endIcon = if (state.isEnabled && state.isTangemIconVisible) { + TangemButtonIconPosition.End(R.drawable.ic_tangem_24) + } else { + TangemButtonIconPosition.None + } + PrimaryButtonIconEnd( + modifier = modifier, + text = state.text.resolveReference(), + iconResId = endIcon.iconResId, + onClick = state.onConfirmClick, + enabled = state.isEnabled, + showProgress = state.showProgress, + ) +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PreviewProvider::class) state: AddTokenUM) { + TangemThemePreview { + AddTokenContent( + state = state, + ) + } +} + +private class PreviewProvider : PreviewParameterProvider { + private val tokenState + get() = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = {}, + onItemLongClick = {}, + ) + + val networkUM + get() = AddTokenUM.Network( + icon = R.drawable.img_eth_22, + name = stringReference("Ethereum"), + editable = true, + onClick = {}, + ) + + val button + get() = AddTokenUM.Button( + isEnabled = false, + showProgress = false, + isTangemIconVisible = false, + text = resourceReference(R.string.common_add), + onConfirmClick = { }, + ) + + val account + get() = AddTokenUM.Portfolio( + accountIconUM = AccountIconPreviewData.randomAccountIcon(), + name = AccountName.DefaultMain.toUM().value, + editable = true, + onClick = {}, + ) + val wallet + get() = AddTokenUM.Portfolio( + accountIconUM = null, + name = stringReference("Wallet"), + editable = true, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + AddTokenUM( + tokenToAdd = tokenState, + network = networkUM, + portfolio = account, + button = button, + ), + AddTokenUM( + tokenToAdd = tokenState, + network = networkUM, + portfolio = wallet, + button = button, + ), + AddTokenUM( + tokenToAdd = tokenState, + network = networkUM.copy(editable = false), + portfolio = wallet.copy(editable = false), + button = button.copy( + isEnabled = true, + isTangemIconVisible = true, + ), + ), + ) +} \ 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/TokenActionsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt new file mode 100644 index 0000000000..97e8645655 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt @@ -0,0 +1,204 @@ +package com.tangem.features.markets.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.vector.ImageVector +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 androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.icons.badge.drawBadge +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +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.markets.impl.R +import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Composable +internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenItem( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = state.token, + isBalanceHidden = false, + ) + + SpacerH(TangemTheme.dimens.spacing14) + Column( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.quickActions.actions.fastForEach { + key(it.title) { + ActionRow( + state = it, + onClick = { state.quickActions.onQuickActionClick(it) }, + onLongClick = { state.quickActions.onQuickActionLongClick(it) }, + ) + } + } + } + + SpacerH16() + + SecondaryButton( + modifier = modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_later), + onClick = state.onLaterClick, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ActionRow( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit), + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal = { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal.takeIf { state.longClickAvailable }, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + val containerColor = TangemTheme.colors.background.action + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ) + .size(36.dp) + .drawWithContent { + drawContent() + if (state is QuickActionUM.Exchange && state.showBadge) { + drawBadge(containerColor = containerColor, offset = 4.dp) + } + }, + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) { + TangemThemePreview { + TokenActionsContent( + state = state, + ) + } +} + +private class TokenActionsContentPreviewProvider : PreviewParameterProvider { + private val tokenState + get() = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = {}, + onItemLongClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + TokenActionsUM( + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(showBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + token = tokenState, + onLaterClick = {}, + ), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt new file mode 100644 index 0000000000..7ab163ffb2 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt @@ -0,0 +1,37 @@ +package com.tangem.features.markets.portfolio.add.impl.ui.state + +import androidx.annotation.DrawableRes +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference + +data class AddTokenUM( + val tokenToAdd: TokenItemState, + val network: Network, + val portfolio: Portfolio, + val button: Button, +) { + data class Portfolio( + val accountIconUM: CryptoPortfolioIconUM?, + val name: TextReference, + val editable: Boolean, + val onClick: () -> Unit, + ) { + val isAccountMode get() = accountIconUM != null + } + + data class Network( + @DrawableRes val icon: Int, + val name: TextReference, + val editable: Boolean, + val onClick: () -> Unit, + ) + + data class Button( + val isEnabled: Boolean, + val showProgress: Boolean, + val isTangemIconVisible: Boolean, + val onConfirmClick: () -> Unit, + val text: TextReference, + ) +} \ 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/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/TokenActionsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/TokenActionsUM.kt new file mode 100644 index 0000000000..cb2466e02a --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/TokenActionsUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.markets.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM + +internal data class TokenActionsUM( + val token: TokenItemState, + val quickActions: PortfolioTokenUM.QuickActions, + val onLaterClick: () -> Unit, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index 913963f668..3b1183b867 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -2,9 +2,9 @@ package com.tangem.features.markets.portfolio.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse -import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -22,16 +22,13 @@ import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.markets.SaveMarketTokensUseCase import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase -import com.tangem.domain.transaction.usecase.GetEnsNameUseCase +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.features.markets.impl.R @@ -70,9 +67,8 @@ internal class MarketsPortfolioModel @Inject constructor( private val addToPortfolioManager: AddToPortfolioManager, private val analyticsEventHandler: AnalyticsEventHandler, private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, - private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, - private val getEnsNameUseCase: GetEnsNameUseCase, private val userWalletImageFetcher: UserWalletImageFetcher, + private val receiveAddressesFactory: ReceiveAddressesFactory, ) : Model() { val state: StateFlow get() = _state @@ -374,50 +370,16 @@ internal class MarketsPortfolioModel @Inject constructor( } private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) { - when (quickAction.action) { - TokenActionsBSContentUM.Action.Receive -> { - val addresses = quickAction.cryptoCurrencyData.status.value.networkAddress ?: return - val cryptoCurrency = quickAction.cryptoCurrencyData.status.currency - modelScope.launch { - val ensName = getEnsNameUseCase.invoke( - userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, - network = cryptoCurrency.network, - address = addresses.defaultAddress.value, - ) - - val receiveAddresses = buildList { - ensName?.let { ens -> - add( - ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, - value = ens, - ), - ) - } - addresses.availableAddresses.map { address -> - add( - ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy - }, - value = address.value, - ), - ) - } - } - val tokenConfig = TokenReceiveConfig( - shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(), - cryptoCurrency = cryptoCurrency, - userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, - showMemoDisclaimer = cryptoCurrency.network.transactionExtrasType != Network - .TransactionExtrasType.NONE, - receiveAddress = receiveAddresses, - ) - bottomSheetNavigation.activate(tokenConfig) - } + val isNewReceive = quickAction.action == TokenActionsBSContentUM.Action.Receive && + tokenReceiveFeatureToggle.isNewTokenReceiveEnabled + if (isNewReceive) { + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = quickAction.cryptoCurrencyData.status, + userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(tokenConfig) } - else -> Unit } } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt index e1acbcf4e1..2bb27f2a78 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -42,55 +42,60 @@ internal class PortfolioTokenUMConverter( walletId = value.userWallet.walletId, isBalanceHidden = isBalanceHidden, isQuickActionsShown = false, - quickActions = quickActions(cryptoData = value), + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), ) } - private fun quickActions(cryptoData: PortfolioData.CryptoCurrencyData): PortfolioTokenUM.QuickActions { - return PortfolioTokenUM.QuickActions( - actions = toQuickActions(cryptoData.actions), - onQuickActionClick = { - when (it) { - QuickActionUM.Buy -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Buy, - cryptoCurrencyData = cryptoData, - ) - is QuickActionUM.Exchange -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Exchange, - cryptoCurrencyData = cryptoData, - ) - QuickActionUM.Receive -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Receive, - cryptoCurrencyData = cryptoData, - ) - QuickActionUM.Stake -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Stake, - cryptoCurrencyData = cryptoData, - ) - } - }, - onQuickActionLongClick = { - if (it == QuickActionUM.Receive) { - tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.CopyAddress, - cryptoCurrencyData = cryptoData, - ) - } - }, - ) - } - - private fun toQuickActions(actions: List) = buildList { - actions.forEach { action -> - if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { - when (action) { - is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy - is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(showBadge = action.showBadge) - is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive - is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake - else -> null - }?.let(::add) - } + companion object { + fun quickActions( + cryptoData: PortfolioData.CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + ): PortfolioTokenUM.QuickActions { + return PortfolioTokenUM.QuickActions( + actions = toQuickActions(cryptoData.actions), + onQuickActionClick = { + when (it) { + QuickActionUM.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + } + }, + onQuickActionLongClick = { + if (it == QuickActionUM.Receive) { + tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.CopyAddress, + cryptoCurrencyData = cryptoData, + ) + } + }, + ) } - }.toImmutableList() + + private fun toQuickActions(actions: List) = buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(showBadge = action.showBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } } \ No newline at end of file diff --git a/features/nft/api/build.gradle.kts b/features/nft/api/build.gradle.kts index d9217b0692..23f846a5b2 100644 --- a/features/nft/api/build.gradle.kts +++ b/features/nft/api/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.nft.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.account) /* Project - Core */ implementation(projects.core.decompose) diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt index 801c8f6420..ca6725ce36 100644 --- a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt @@ -3,6 +3,7 @@ package com.tangem.features.nft.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.models.NFTAsset @@ -12,7 +13,9 @@ interface NFTDetailsBlockComponent : ComposableContentComponent { val userWalletId: UserWalletId, val nftAsset: NFTAsset, val nftCollectionName: String, - val title: TextReference, + val account: Account.CryptoPortfolio?, + val isAccountsMode: Boolean, + val walletTitle: TextReference, val isSuccessScreen: Boolean, ) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt index 4856ca5634..a2b7f2f839 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt @@ -2,11 +2,15 @@ package com.tangem.features.nft.details.block import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.nft.component.NFTDetailsBlockComponent import com.tangem.features.nft.details.block.ui.NFTDetailsBlock +import com.tangem.features.nft.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -16,13 +20,23 @@ class DefaultNFTDetailsBlockComponent @AssistedInject constructor( @Assisted private val params: NFTDetailsBlockComponent.Params, ) : NFTDetailsBlockComponent, AppComponentContext by context { + private val account = params.account + @Composable override fun Content(modifier: Modifier) { NFTDetailsBlock( assetName = stringReference(params.nftAsset.name.orEmpty()), collectionName = stringReference(params.nftCollectionName), assetImage = params.nftAsset.media?.imageUrl, - title = params.title, + accountTitleUM = if (account != null && params.isAccountsMode) { + AccountTitleUM.Account( + prefixText = resourceReference(R.string.common_from), + name = account.accountName.toUM().value, + icon = account.icon.toUM(), + ) + } else { + AccountTitleUM.Text(params.walletTitle) + }, isSuccessScreen = params.isSuccessScreen, networkIconRes = getActiveIconRes(params.nftAsset.network.rawId), ) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt index 8d7d1148c9..036ee87161 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference @@ -23,7 +25,7 @@ import com.tangem.features.nft.impl.R @Suppress("LongParameterList") @Composable internal fun NFTDetailsBlock( - title: TextReference, + accountTitleUM: AccountTitleUM, assetName: TextReference, collectionName: TextReference, assetImage: String?, @@ -38,11 +40,7 @@ internal fun NFTDetailsBlock( .padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) + AccountTitle(accountTitleUM) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -91,7 +89,7 @@ private fun NFTDetailsBlock_Preview() { collectionName = stringReference("NFT Collection"), assetImage = null, networkIconRes = R.drawable.img_polygon_22, - title = stringReference("From My Wallet"), + accountTitleUM = AccountTitleUM.Text(stringReference("From My Wallet")), isSuccessScreen = false, ) } diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt index 0439b74aea..1a9c902d2a 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt @@ -14,10 +14,7 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage -import com.tangem.domain.models.Asset -import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig -import com.tangem.domain.models.TokenReceiveNotification import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus @@ -26,8 +23,7 @@ import com.tangem.domain.nft.GetNFTCurrencyUseCase import com.tangem.domain.nft.GetNFTNetworkStatusUseCase import com.tangem.domain.nft.GetNFTNetworksUseCase import com.tangem.domain.nft.analytics.NFTAnalyticsEvent -import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase -import com.tangem.domain.transaction.usecase.GetEnsNameUseCase +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory import com.tangem.features.nft.impl.R import com.tangem.features.nft.receive.NFTReceiveComponent import com.tangem.features.nft.receive.entity.NFTReceiveUM @@ -36,7 +32,6 @@ import com.tangem.features.nft.receive.entity.transformer.ToggleSearchBarTransfo import com.tangem.features.nft.receive.entity.transformer.UpdateDataStateTransformer import com.tangem.features.nft.receive.entity.transformer.UpdateSearchQueryTransformer import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle -import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.* @@ -56,9 +51,8 @@ internal class NFTReceiveModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val messageSender: UiMessageSender, private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, - private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, - private val getEnsNameUseCase: GetEnsNameUseCase, private val getNFTCurrencyUseCase: GetNFTCurrencyUseCase, + private val receiveAddressesFactory: ReceiveAddressesFactory, paramsContainer: ParamsContainer, ) : Model() { @@ -196,54 +190,11 @@ internal class NFTReceiveModel @Inject constructor( private suspend fun configureReceiveAddresses(addresses: NetworkAddress, network: Network): TokenReceiveConfig { val cryptoCurrency = getNFTCurrencyUseCase.invoke(network) - - val ensName = getEnsNameUseCase.invoke( + return receiveAddressesFactory.createForNft( userWalletId = params.userWalletId, + addresses = addresses, network = network, - address = addresses.defaultAddress.value, - ) - - val receiveAddresses = buildList { - ensName?.let { ens -> - add( - ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, - value = ens, - ), - ) - } - addresses.availableAddresses.map { address -> - add( - ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy - }, - value = address.value, - ), - ) - } - } - - val notifications = buildList { - if (BlockchainUtils.isSolana(network.rawId)) { - add( - TokenReceiveNotification( - title = R.string.nft_receive_unsupported_types, - subtitle = R.string.nft_receive_unsupported_types_description, - ), - ) - } - } - - return TokenReceiveConfig( - shouldShowWarning = Asset.NFT.name !in getViewedTokenReceiveWarningUseCase(), - cryptoCurrency = cryptoCurrency, - userWalletId = params.userWalletId, - showMemoDisclaimer = false, - receiveAddress = receiveAddresses, - tokenReceiveNotification = notifications, - asset = Asset.NFT, + nft = cryptoCurrency, ) } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt index b80bb62fda..b921e0641d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt @@ -14,11 +14,11 @@ import com.tangem.core.error.UniversalError import com.tangem.core.error.ext.universalError import com.tangem.core.ui.utils.showErrorDialog import com.tangem.domain.visa.error.VisaApiError -import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.model.VisaActivationInput import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent @@ -57,11 +57,12 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( ), ) - private val activationInput = when (val status = params.scanResponse.visaCardActivationStatus) { - is VisaCardActivationStatus.NotStartedActivation -> status.activationInput - is VisaCardActivationStatus.ActivationStarted -> status.activationInput - else -> error("Visa activation status is not set or incorrect for this step") - } + private val activationInput: VisaActivationInput = TODO("Fix visaCardActivationStatus retrieval") + // when (val status = params.scanResponse.visaCardActivationStatus) { + // is VisaCardActivationStatus.NotStartedActivation -> status.activationInput + // is VisaCardActivationStatus.ActivationStarted -> status.activationInput + // else -> error("Visa activation status is not set or incorrect for this step") + // } private val _uiState = MutableStateFlow(getInitialState()) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt index 1786357959..3e7fbaa01c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt @@ -12,15 +12,15 @@ import com.tangem.core.ui.utils.showErrorDialog import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.datasource.local.visa.VisaOTPStorage import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.repository.VisaActivationRepository -import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params @@ -180,13 +180,15 @@ internal class OnboardingVisaInProgressModel @Inject constructor( onDone.emit(Params.DoneEvent.Activated) } + @Suppress("UnusedPrivateProperty") private suspend fun createUserWallet(scanResponse: ScanResponse, authTokens: VisaAuthTokens): UserWallet = withContext(dispatchers.io) { val newActivationStatus = VisaCardActivationStatus.Activated(visaAuthTokens = authTokens) requireNotNull( value = coldUserWalletBuilderFactory.create( - scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus), + // scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus), + scanResponse = scanResponse, ).build(), lazyMessage = { "User wallet not created" }, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt index 4de22cecd9..c5df13cdd0 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt @@ -10,10 +10,6 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.card.common.visa.VisaWalletPublicKeyUtility -import com.tangem.domain.visa.model.VisaActivationRemoteState -import com.tangem.domain.visa.model.VisaCardActivationStatus -import com.tangem.domain.visa.model.VisaCardWalletDataToSignRequest -import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -143,74 +139,75 @@ internal class OnboardingVisaModel @Inject constructor( } private fun initializeRoute(): OnboardingVisaRoute { - return when (val activationStatus = params.scanResponse.visaCardActivationStatus) { - is VisaCardActivationStatus.ActivationStarted -> { - when (val remoteState = activationStatus.remoteState) { - is VisaActivationRemoteState.CardWalletSignatureRequired -> { - if (activationStatus.activationInput.isAccessCodeSet) { - OnboardingVisaRoute.WelcomeBack( - activationInput = activationStatus.activationInput, - dataToSignByCardWalletRequest = VisaCardWalletDataToSignRequest( - activationOrderInfo = remoteState.activationOrderInfo, - cardWalletAddress = activationStatus.cardWalletAddress, - ), - ) - } else { - OnboardingVisaRoute.AccessCode - } - } - is VisaActivationRemoteState.CustomerWalletSignatureRequired -> { - remoteState.getRoute(activationStatus) - } - VisaActivationRemoteState.PaymentAccountDeploying -> { - OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve) - } - VisaActivationRemoteState.WaitingForActivationFinishing -> { - OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode) - } - is VisaActivationRemoteState.AwaitingPinCode -> { - OnboardingVisaRoute.PinCode( - activationOrderInfo = remoteState.activationOrderInfo, - pinCodeValidationError = false, - ) - } - VisaActivationRemoteState.Activated, - VisaActivationRemoteState.BlockedForActivation, - VisaActivationRemoteState.Failed, - -> error("Activation status is not correct for onboarding flow") - } - } - is VisaCardActivationStatus.NotStartedActivation -> OnboardingVisaRoute.Welcome - else -> error("Visa activation status is not correct for onboarding flow") - } + TODO("Fix visaCardActivationStatus retrieval") + // return when (val activationStatus = params.scanResponse.visaCardActivationStatus) { + // is VisaCardActivationStatus.ActivationStarted -> { + // when (val remoteState = activationStatus.remoteState) { + // is VisaActivationRemoteState.CardWalletSignatureRequired -> { + // if (activationStatus.activationInput.isAccessCodeSet) { + // OnboardingVisaRoute.WelcomeBack( + // activationInput = activationStatus.activationInput, + // dataToSignByCardWalletRequest = VisaCardWalletDataToSignRequest( + // activationOrderInfo = remoteState.activationOrderInfo, + // cardWalletAddress = activationStatus.cardWalletAddress, + // ), + // ) + // } else { + // OnboardingVisaRoute.AccessCode + // } + // } + // is VisaActivationRemoteState.CustomerWalletSignatureRequired -> { + // remoteState.getRoute(activationStatus) + // } + // VisaActivationRemoteState.PaymentAccountDeploying -> { + // OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve) + // } + // VisaActivationRemoteState.WaitingForActivationFinishing -> { + // OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode) + // } + // is VisaActivationRemoteState.AwaitingPinCode -> { + // OnboardingVisaRoute.PinCode( + // activationOrderInfo = remoteState.activationOrderInfo, + // pinCodeValidationError = false, + // ) + // } + // VisaActivationRemoteState.Activated, + // VisaActivationRemoteState.BlockedForActivation, + // VisaActivationRemoteState.Failed, + // -> error("Activation status is not correct for onboarding flow") + // } + // } + // is VisaCardActivationStatus.NotStartedActivation -> OnboardingVisaRoute.Welcome + // else -> error("Visa activation status is not correct for onboarding flow") + // } } - private fun VisaActivationRemoteState.CustomerWalletSignatureRequired.getRoute( - activationStatus: VisaCardActivationStatus.ActivationStarted, - ): OnboardingVisaRoute { - val foundWalletCardId = tryToFindExistingWalletCardId(this.activationOrderInfo.customerWalletAddress) - val request = VisaCustomerWalletDataToSignRequest( - orderId = this.activationOrderInfo.orderId, - cardWalletAddress = activationStatus.cardWalletAddress, - customerWalletAddress = this.activationOrderInfo.customerWalletAddress, - ) - val preparationDataForApprove = PreparationDataForApprove( - customerWalletAddress = this.activationOrderInfo.customerWalletAddress, - request = request, - ) - - return if (foundWalletCardId != null) { - OnboardingVisaRoute.TangemWalletApproveOption( - preparationDataForApprove = preparationDataForApprove, - foundWalletCardId = foundWalletCardId, - allowNavigateBack = false, - ) - } else { - OnboardingVisaRoute.ChooseWallet( - preparationDataForApprove = preparationDataForApprove, - ) - } - } + // private fun VisaActivationRemoteState.CustomerWalletSignatureRequired.getRoute( + // activationStatus: VisaCardActivationStatus.ActivationStarted, + // ): OnboardingVisaRoute { + // val foundWalletCardId = tryToFindExistingWalletCardId(this.activationOrderInfo.customerWalletAddress) + // val request = VisaCustomerWalletDataToSignRequest( + // orderId = this.activationOrderInfo.orderId, + // cardWalletAddress = activationStatus.cardWalletAddress, + // customerWalletAddress = this.activationOrderInfo.customerWalletAddress, + // ) + // val preparationDataForApprove = PreparationDataForApprove( + // customerWalletAddress = this.activationOrderInfo.customerWalletAddress, + // request = request, + // ) + // + // return if (foundWalletCardId != null) { + // OnboardingVisaRoute.TangemWalletApproveOption( + // preparationDataForApprove = preparationDataForApprove, + // foundWalletCardId = foundWalletCardId, + // allowNavigateBack = false, + // ) + // } else { + // OnboardingVisaRoute.ChooseWallet( + // preparationDataForApprove = preparationDataForApprove, + // ) + // } + // } private fun tryToFindExistingWalletCardId(targetAddress: String): String? { val wallets = getWalletsUseCase.invokeSync().filter { it.isLocked.not() } 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 Unit = { bottomSheetNavigation::dismiss } + override val onBack: () -> Unit = { bottomSheetNavigation::dismiss } + } + init { analyticsEventHandler.send(ReferralEvents.ReferralScreenOpened) if (accountsFeatureToggles.isFeatureEnabled) { @@ -172,7 +179,7 @@ internal class ReferralModel @Inject constructor( modelScope.launch { val lastInfoState = uiState.referralInfoState val portfolioId = when (accountsFeatureToggles.isFeatureEnabled) { - true -> PortfolioId(requireNotNull(portfolioSelectorController.selectedAccount.value)) + true -> PortfolioId(requireNotNull(portfolioSelectorController.selectedAccountSync)) false -> PortfolioId(params.userWalletId) } runCatching { referralInteractor.startReferral(portfolioId) } @@ -259,7 +266,7 @@ internal class ReferralModel @Inject constructor( private suspend fun selectAccount(referralData: ReferralData) { when (referralData) { - is ReferralData.NonParticipantData -> if (portfolioSelectorController.selectedAccount.value == null) { + is ReferralData.NonParticipantData -> if (portfolioSelectorController.selectedAccountSync == null) { portfolioSelectorController.selectAccount( accountId = walletAccounts(params.userWalletId).first().mainAccount.account.accountId, ) diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt index b8fa5480a5..8a0a3d2976 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.send.v2.api.subcomponents.destination.entity import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountTitleUM import kotlinx.collections.immutable.ImmutableList @Immutable @@ -15,6 +16,7 @@ sealed class DestinationUM { val recent: ImmutableList, val wallets: ImmutableList, val networkName: String, + val accountTitleUM: AccountTitleUM?, val isValidating: Boolean = false, val isInitialized: Boolean = false, val isRecentHidden: Boolean, diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index 9d3830f696..a4297a69f1 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -68,6 +68,8 @@ dependencies { implementation(projects.domain.nft) implementation(projects.domain.notifications) implementation(projects.domain.swap.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) /** Compose libraries */ diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt index f30c3fa513..858747f091 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -175,6 +175,8 @@ internal class DefaultSendComponent @AssistedInject constructor( userWalletId = params.userWalletId, cryptoCurrency = params.currency, cryptoCurrencyStatusFlow = model.cryptoCurrencyStatusFlow, + accountFlow = model.accountFlow, + isAccountModeFlow = model.isAccountModeFlow, callback = model, predefinedValues = model.predefinedValues, analyticsSendSource = model.analyticsSendSource, @@ -201,6 +203,8 @@ internal class DefaultSendComponent @AssistedInject constructor( feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, cryptoCurrencyStatusFlow = model.cryptoCurrencyStatusFlow, feeCryptoCurrencyStatusFlow = model.feeCryptoCurrencyStatusFlow, + accountFlow = model.accountFlow, + isAccountModeFlow = model.isAccountModeFlow, appCurrency = model.appCurrency, callback = model, predefinedValues = model.predefinedValues, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index 154ab7e564..02c4c88993 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -11,6 +11,7 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.GetFeeError @@ -73,6 +74,8 @@ internal class SendConfirmComponent( cryptoCurrencyStatusFlow = params.cryptoCurrencyStatusFlow, isBalanceHidingFlow = params.isBalanceHidingFlow, analyticsSendSource = params.analyticsSendSource, + accountFlow = params.accountFlow, + isAccountModeFlow = params.isAccountModeFlow, ), onResult = model::onAmountResult, onClick = model::showEditAmount, @@ -152,6 +155,8 @@ internal class SendConfirmComponent( val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val cryptoCurrencyStatusFlow: StateFlow, val feeCryptoCurrencyStatusFlow: StateFlow, + val accountFlow: StateFlow, + val isAccountModeFlow: StateFlow, val appCurrency: AppCurrency, val callback: ModelCallback, val currentRoute: Flow, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 141773d3c7..38c5c33abd 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -14,6 +14,9 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -23,6 +26,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency @@ -91,8 +95,11 @@ internal class SendModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val sendAmountUpdateTrigger: SendAmountUpdateTrigger, private val analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, ) : Model(), SendComponentCallback { private val params: SendComponent.Params = paramsContainer.require() @@ -115,21 +122,26 @@ internal class SendModel @Inject constructor( val currentRoute = MutableStateFlow(initialRoute) - private val _cryptoCurrencyStatusFlow = MutableStateFlow( - CryptoCurrencyStatus( - params.currency, - value = CryptoCurrencyStatus.Loading, - ), - ) - val cryptoCurrencyStatusFlow = _cryptoCurrencyStatusFlow.asStateFlow() + val cryptoCurrencyStatusFlow: StateFlow + field = MutableStateFlow( + CryptoCurrencyStatus( + params.currency, + value = CryptoCurrencyStatus.Loading, + ), + ) - private val _feeCryptoCurrencyStatusFlow = MutableStateFlow( - CryptoCurrencyStatus( - params.currency, - value = CryptoCurrencyStatus.Loading, - ), - ) - val feeCryptoCurrencyStatusFlow = _feeCryptoCurrencyStatusFlow.asStateFlow() + val feeCryptoCurrencyStatusFlow: StateFlow + field = MutableStateFlow( + CryptoCurrencyStatus( + params.currency, + value = CryptoCurrencyStatus.Loading, + ), + ) + + val accountFlow: StateFlow + field = MutableStateFlow(null) + val isAccountModeFlow: StateFlow + field = MutableStateFlow(false) var userWallet: UserWallet by Delegates.notNull() var appCurrency: AppCurrency = AppCurrency.Default @@ -317,13 +329,34 @@ internal class SendModel @Inject constructor( ifRight = { wallet -> userWallet = wallet - val isSingleWalletWithToken = wallet is UserWallet.Cold && - wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() - val isMultiCurrency = wallet.isMultiCurrency - getCurrenciesStatusUpdates( - isSingleWalletWithToken = isSingleWalletWithToken, - isMultiCurrency = isMultiCurrency, - ) + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase( + userWalletId = params.userWalletId, + currency = cryptoCurrency, + ).onEach { (account, cryptoCurrencyStatus) -> + cryptoCurrencyStatusFlow.value = cryptoCurrencyStatus + feeCryptoCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = params.userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: cryptoCurrencyStatus + + isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync() + accountFlow.value = account + + if (params.amount != null) { + router.replaceAll(Confirm) + } + }.flowOn(dispatchers.default) + .launchIn(modelScope) + } else { + val isSingleWalletWithToken = wallet is UserWallet.Cold && + wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() + val isMultiCurrency = wallet.isMultiCurrency + getCurrenciesStatusUpdates( + isSingleWalletWithToken = isSingleWalletWithToken, + isMultiCurrency = isMultiCurrency, + ) + } }, ifLeft = { Timber.w(it.toString()) @@ -352,10 +385,12 @@ internal class SendModel @Inject constructor( ).onEach { maybeCryptoCurrency -> maybeCryptoCurrency.fold( ifRight = { cryptoCurrencyStatus -> - onDataLoaded( - currencyStatus = cryptoCurrencyStatus, - feeCurrencyStatus = getFeeCurrencyStatus(cryptoCurrencyStatus, isMultiCurrency), - ) + cryptoCurrencyStatusFlow.value = cryptoCurrencyStatus + feeCryptoCurrencyStatusFlow.value = getFeeCurrencyStatus(cryptoCurrencyStatus, isMultiCurrency) + + if (params.amount != null) { + router.replaceAll(CommonSendRoute.Confirm) + } }, ifLeft = { sendConfirmAlertFactory.getGenericErrorState( @@ -366,7 +401,8 @@ internal class SendModel @Inject constructor( ) }, ) - }.launchIn(modelScope) + }.flowOn(dispatchers.default) + .launchIn(modelScope) } private fun getCurrencyStatus( @@ -402,15 +438,6 @@ internal class SendModel @Inject constructor( } } - private fun onDataLoaded(currencyStatus: CryptoCurrencyStatus, feeCurrencyStatus: CryptoCurrencyStatus) { - _cryptoCurrencyStatusFlow.value = currencyStatus - _feeCryptoCurrencyStatusFlow.value = feeCurrencyStatus - - if (params.amount != null) { - router.replaceAll(CommonSendRoute.Confirm) - } - } - private fun subscribeOnQRScannerResult() { listenToQrScanningUseCase(SourceType.SEND) .getOrElse { emptyFlow() } @@ -454,7 +481,7 @@ internal class SendModel @Inject constructor( } private fun initialState(): SendUM = SendUM( - amountUM = AmountState.Empty(isRedesignEnabled = true), + amountUM = AmountState.Empty, destinationUM = SendDestinationInitialStateTransformer( cryptoCurrency = cryptoCurrency, ).transform(DestinationUM.Empty()), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt index 13d6256eed..c8992e8ca4 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt @@ -156,6 +156,8 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( isBalanceHidingFlow = model.isBalanceHiddenFlow, onLoadFee = model::loadFee, analyticsSendSource = analyticsSendSource, + account = model.account, + isAccountsMode = model.isAccountsMode, onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, ), ) @@ -181,6 +183,8 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( callback = model, currentRoute = model.currentRouteFlow.filterIsInstance(), txUrl = txUrl, + account = model.account, + isAccountsMode = model.isAccountsMode, ), ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt index 851eb6cbd5..8bf26ce732 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt @@ -13,6 +13,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.models.NFTAsset @@ -88,7 +89,9 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( nftAsset = params.nftAsset, nftCollectionName = params.nftCollectionName, isSuccessScreen = false, - title = resourceReference(R.string.send_from_wallet_name, wrappedList(params.userWallet.name)), + account = params.account, + isAccountsMode = params.isAccountsMode, + walletTitle = resourceReference(R.string.send_from_wallet_name, wrappedList(params.userWallet.name)), ), ) @@ -150,6 +153,8 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( val nftCollectionName: String, val cryptoCurrencyStatus: CryptoCurrencyStatus, val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val account: Account.CryptoPortfolio?, + val isAccountsMode: Boolean, val callback: ModelCallback, val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt index 277b66f0a2..5128c82ff5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -11,6 +11,9 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.card.common.util.cardTypesResolver @@ -19,6 +22,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -71,6 +75,9 @@ internal class NFTSendModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val alertFactory: SendConfirmAlertFactory, private val nftSendSuccessTrigger: NFTSendSuccessTrigger, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, ) : Model(), SendNFTComponentCallback, NFTSendSuccessComponent.ModelCallback { val params: NFTSendComponent.Params = paramsContainer.require() @@ -94,6 +101,9 @@ internal class NFTSendModel @Inject constructor( var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() var appCurrency: AppCurrency = AppCurrency.Default + var account: Account.CryptoPortfolio? = null + var isAccountsMode: Boolean = false + init { subscribeOnCurrencyStatusUpdates() initAppCurrency() @@ -176,10 +186,31 @@ internal class NFTSendModel @Inject constructor( ?.firstOrNull { it is CryptoCurrency.Coin && it.network == nftAsset.network } ?: return@launch - getCurrenciesStatusUpdates( - isSingleWalletWithToken = wallet is UserWallet.Cold && - wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), - ) + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase( + userWalletId, + cryptoCurrency, + ).onEach { (maybeAccount, cryptoStatus) -> + account = maybeAccount + isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() + + cryptoCurrencyStatus = cryptoStatus + feeCryptoCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoStatus, + ).getOrNull() ?: cryptoStatus + + if (uiState.value.destinationUM is DestinationUM.Empty) { + router.replaceAll(Destination(isEditMode = false)) + } + }.flowOn(dispatchers.default) + .launchIn(modelScope) + } else { + getCurrenciesStatusUpdates( + isSingleWalletWithToken = wallet is UserWallet.Cold && + wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + } }, ifLeft = { alertFactory.getGenericErrorState(::onFailedTxEmailClick) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/NFTSendSuccessComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/NFTSendSuccessComponent.kt index a1647cd8b5..59f31637b6 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/NFTSendSuccessComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/NFTSendSuccessComponent.kt @@ -10,6 +10,7 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference +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.nft.models.NFTAsset @@ -46,7 +47,9 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( nftAsset = params.nftAsset, nftCollectionName = params.nftCollectionName, isSuccessScreen = true, - title = resourceReference(R.string.nft_asset), + account = params.account, + isAccountsMode = params.isAccountsMode, + walletTitle = resourceReference(R.string.nft_asset), ), ) @@ -86,6 +89,8 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( val nftAsset: NFTAsset, val nftCollectionName: String, val txUrl: String, + val account: Account.CryptoPortfolio?, + val isAccountsMode: Boolean, val callback: ModelCallback, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt index 0dcbee1083..acae2c05d8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt @@ -26,12 +26,10 @@ internal class SendAmountComponent( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle() val isSendWithSwapAvailable by model.isSendWithSwapAvailable.collectAsStateWithLifecycle() SendAmountContent( amountState = state, - isBalanceHidden = isBalanceHidden, clickIntents = model, isSendWithSwapAvailable = isSendWithSwapAvailable, modifier = modifier, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt index 408453631d..d776414db6 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt @@ -2,6 +2,7 @@ package com.tangem.features.send.v2.subcomponents.amount import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -23,6 +24,8 @@ internal sealed class SendAmountComponentParams { abstract val cryptoCurrency: CryptoCurrency abstract val cryptoCurrencyStatusFlow: StateFlow abstract val isBalanceHidingFlow: StateFlow + abstract val accountFlow: StateFlow + abstract val isAccountModeFlow: StateFlow data class AmountParams( override val state: AmountState, @@ -34,6 +37,8 @@ internal sealed class SendAmountComponentParams { override val cryptoCurrencyStatusFlow: StateFlow, override val isBalanceHidingFlow: StateFlow, override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + override val accountFlow: StateFlow, + override val isAccountModeFlow: StateFlow, val callback: ModelCallback, val currentRoute: StateFlow, ) : SendAmountComponentParams() @@ -48,6 +53,8 @@ internal sealed class SendAmountComponentParams { override val cryptoCurrencyStatusFlow: StateFlow, override val isBalanceHidingFlow: StateFlow, override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + override val accountFlow: StateFlow, + override val isAccountModeFlow: StateFlow, val userWallet: UserWallet, val blockClickEnableFlow: StateFlow, ) : SendAmountComponentParams() diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index 4d3ac6848c..cd838ffa32 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -21,6 +21,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.exchange.RampStateManager +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.isMultiCurrency @@ -118,42 +119,7 @@ internal class SendAmountModel @Inject constructor( private fun subscribeOnBalanceHiddenUpdates() { params.isBalanceHidingFlow.onEach { isBalanceHidden -> - _uiState.update( - AmountBoundaryUpdateTransformer( - cryptoCurrencyStatus = cryptoCurrencyStatus, - maxEnterAmount = maxAmountBoundary, - appCurrency = appCurrency, - isBalanceHidden = params.isBalanceHidingFlow.value, - ), - ) - }.launchIn(modelScope) - } - - private fun subscribeOnCryptoCurrencyStatusFlow() { - params.cryptoCurrencyStatusFlow - .onEach { newCryptoCurrencyStatus -> - cryptoCurrencyStatus = newCryptoCurrencyStatus - maxAmountBoundary = MaxEnterAmountConverter().convert(cryptoCurrencyStatus) - initMinBoundary() - } - .launchIn(modelScope) - } - - private fun initMinBoundary() { - modelScope.launch { - minAmountBoundary = getMinimumTransactionAmountSyncUseCase( - userWalletId = params.userWalletId, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ).getOrNull()?.let { - EnterAmountBoundary( - amount = it, - fiatRate = cryptoCurrencyStatus.value.fiatRate.orZero(), - ) - } - - appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } - - if (uiState.value is AmountState.Data) { + if (cryptoCurrencyStatus.value != CryptoCurrencyStatus.Loading) { _uiState.update( AmountBoundaryUpdateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -162,33 +128,87 @@ internal class SendAmountModel @Inject constructor( isBalanceHidden = params.isBalanceHidingFlow.value, ), ) - } else { - initialState() } + }.launchIn(modelScope) + } + + private fun subscribeOnCryptoCurrencyStatusFlow() { + combine( + flow = params.cryptoCurrencyStatusFlow.distinctUntilChanged { old, new -> + old.value.amount == new.value.amount + }, // Check only balance changes, + flow2 = params.accountFlow, + flow3 = params.isAccountModeFlow, + ) { newCryptoCurrencyStatus, account, isAccountsMode -> + maxAmountBoundary = MaxEnterAmountConverter().convert(newCryptoCurrencyStatus) + cryptoCurrencyStatus = newCryptoCurrencyStatus + initMinBoundary(cryptoCurrencyStatus, account, isAccountsMode) + }.flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private suspend fun initMinBoundary( + cryptoCurrencyStatus: CryptoCurrencyStatus, + account: Account.CryptoPortfolio?, + isAccountsMode: Boolean, + ) { + minAmountBoundary = getMinimumTransactionAmountSyncUseCase( + userWalletId = params.userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull()?.let { + EnterAmountBoundary( + amount = it, + fiatRate = cryptoCurrencyStatus.value.fiatRate.orZero(), + ) + } + + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + + if (uiState.value is AmountState.Data) { + _uiState.update( + AmountBoundaryUpdateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxEnterAmount = maxAmountBoundary, + appCurrency = appCurrency, + isBalanceHidden = params.isBalanceHidingFlow.value, + ), + ) + } else { + initialState(cryptoCurrencyStatus, account, isAccountsMode) } } - private fun initialState() { + private fun initialState( + cryptoCurrencyStatus: CryptoCurrencyStatus, + @Suppress("UnusedParameter") account: Account.CryptoPortfolio?, + @Suppress("UnusedParameter") isAccountsMode: Boolean, + ) { if (uiState.value is AmountState.Empty && userWallet != null) { val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1 + val walletTitle = if (isOnlyOneWallet) { + resourceReference(R.string.send_from_title) + } else { + resourceReference( + R.string.send_from_wallet_name, + WrappedList(listOf(userWallet?.name.orEmpty())), // TODO [REDACTED_TASK_KEY] + ) + } _uiState.update { - AmountStateConverterV2( + AmountStateConverter( clickIntents = this, appCurrency = appCurrency, cryptoCurrencyStatus = cryptoCurrencyStatus, maxEnterAmount = maxAmountBoundary, iconStateConverter = CryptoCurrencyToIconStateConverter(), isBalanceHidden = params.isBalanceHidingFlow.value, + accountTitleUM = AmountAccountConverter( + isAccountsMode = isAccountsMode, + walletTitle = walletTitle, + prefixText = resourceReference(R.string.common_from), + ).convert(account), ).convert( AmountParameters( - title = if (isOnlyOneWallet) { - resourceReference(R.string.send_from_title) - } else { - resourceReference( - R.string.send_from_wallet_name, - WrappedList(listOf(userWallet?.name.orEmpty())), // TODO [REDACTED_TASK_KEY] - ) - }, + title = walletTitle, value = "", ), ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt index b72232c3d4..91252a0eaa 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt @@ -30,7 +30,6 @@ import com.tangem.features.send.v2.subcomponents.amount.ui.preview.SendAmountCli @Composable fun SendAmountContent( amountState: AmountState, - isBalanceHidden: Boolean, clickIntents: SendAmountClickIntents, isSendWithSwapAvailable: Boolean, modifier: Modifier = Modifier, @@ -38,7 +37,6 @@ fun SendAmountContent( Column(modifier = modifier.background(TangemTheme.colors.background.tertiary)) { AmountScreenContent( amountState = amountState, - isBalanceHidden = isBalanceHidden, clickIntents = clickIntents, extraContent = { SendConvertTokenButton( @@ -95,7 +93,6 @@ private fun SendAmountContent_Preview(@PreviewParameter(SendAmountContentPreview TangemThemePreview { SendAmountContent( amountState = params, - isBalanceHidden = true, clickIntents = SendAmountClickIntentsStub, isSendWithSwapAvailable = true, ) @@ -106,6 +103,7 @@ private class SendAmountContentPreviewProvider : PreviewParameterProvider get() = sequenceOf( AmountStatePreviewData.amountStateV2, + AmountStatePreviewData.amountStateV2Accounts, ) } // endregion \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 713ea50d93..dd7319a481 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -11,6 +11,9 @@ 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.extensions.resourceReference +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked @@ -64,7 +67,10 @@ internal class SendDestinationModel @Inject constructor( private val isSelfSendAvailableUseCase: IsSelfSendAvailableUseCase, private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val parseQrCodeUseCase: ParseQrCodeUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, ) : Model(), SendDestinationClickIntents { private val params: SendDestinationComponentParams = paramsContainer.require() @@ -142,7 +148,7 @@ internal class SendDestinationModel @Inject constructor( ) } - fun saveResult() { + private fun saveResult() { val params = params as? SendDestinationComponentParams.DestinationParams ?: return params.callback.onDestinationResult(uiState.value) } @@ -193,12 +199,17 @@ internal class SendDestinationModel @Inject constructor( ).getOrElse { flowOf(emptyList()) }.map { waitForDelay(RECENT_LOAD_DELAY) { it } }.conflate(), - ) { destinationWalletList, txHistoryList -> + flow3 = isAccountsModeEnabledUseCase().distinctUntilChanged(), + flow4 = if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase(userWalletId, cryptoCurrency).distinctUntilChanged() + } else { + flowOf(null) + }, + ) { destinationWalletList, txHistoryList, isAccountsMode, accountCurrencyStatus -> val isSelfSendAvailable = isSelfSendAvailableUseCase.invokeSync( userWalletId = userWalletId, network = cryptoCurrency.network, ) - _uiState.update( SendDestinationRecentListTransformer( cryptoCurrency = cryptoCurrency, @@ -206,9 +217,11 @@ internal class SendDestinationModel @Inject constructor( isSelfSendAvailable = isSelfSendAvailable, destinationWalletList = destinationWalletList, txHistoryList = txHistoryList, + account = accountCurrencyStatus?.account, + isAccountsMode = isAccountsMode, ), ) - }.launchIn(modelScope) + }.flowOn(dispatchers.default).launchIn(modelScope) } private suspend fun List.toAvailableWallets(): List { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt index d90eeef6ee..f9ad07bde8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt @@ -54,6 +54,7 @@ internal class SendDestinationInitialStateTransformer( isValuePasted = false, ) }, + accountTitleUM = null, wallets = loadingListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT), recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT), networkName = cryptoCurrency.network.name, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt index c1c579cbd8..51b11db3df 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt @@ -1,24 +1,42 @@ package com.tangem.features.send.v2.subcomponents.destination.model.transformers +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientHistoryListConverter import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientWalletListConverter import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM +import com.tangem.utils.StringsSigns import com.tangem.utils.transformer.Transformer +@Suppress("LongParameterList") internal class SendDestinationRecentListTransformer( private val senderAddress: String?, private val cryptoCurrency: CryptoCurrency, private val isSelfSendAvailable: Boolean, private val destinationWalletList: List, private val txHistoryList: List, + private val account: Account.CryptoPortfolio?, + private val isAccountsMode: Boolean, ) : Transformer { override fun transform(prevState: DestinationUM): DestinationUM { val state = prevState as? DestinationUM.Content ?: return prevState return state.copy( + accountTitleUM = if (account != null && isAccountsMode) { + AccountTitleUM.Account( + name = account.accountName.toUM().value, + icon = account.icon.toUM(), + prefixText = stringReference(StringsSigns.DOT), + ) + } else { + AccountTitleUM.Text(TextReference.EMPTY) + }, wallets = SendRecipientWalletListConverter( senderAddress = senderAddress, isSelfSendAvailable = isSelfSendAvailable, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt index 134fbad1fb..54989a861c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt @@ -144,6 +144,7 @@ private class DestinationBlockPreviewProvider : PreviewParameterProvider diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt index 458fa9d397..bd9b01c007 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt @@ -23,15 +23,22 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.extensions.rememberHapticFeedback +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.send.v2.impl.R +import com.tangem.utils.StringsSigns /** * Row item with title and subtitle @@ -50,6 +57,7 @@ fun ListItemWithIcon( subtitle: String, onClick: () -> Unit, modifier: Modifier = Modifier, + accountTitleUM: AccountTitleUM? = null, info: String? = null, subtitleEndOffset: Int = 0, @DrawableRes subtitleIconRes: Int? = null, @@ -68,6 +76,7 @@ fun ListItemWithIcon( subtitle = subtitle, onClick = onClick, info = info, + accountTitleUM = accountTitleUM, subtitleEndOffset = subtitleEndOffset, subtitleIconRes = subtitleIconRes, modifier = modifier, @@ -80,6 +89,7 @@ fun ListItemWithIcon( private fun ListItemWithIcon( title: String, subtitle: String, + accountTitleUM: AccountTitleUM?, onClick: () -> Unit, modifier: Modifier = Modifier, info: String? = null, @@ -115,7 +125,7 @@ private fun ListItemWithIcon( ellipsis = TextEllipsis.Middle, modifier = Modifier, ) - Row { + Row(verticalAlignment = Alignment.CenterVertically) { if (subtitleIconRes != null) { Icon( painter = painterResource(id = subtitleIconRes), @@ -142,6 +152,13 @@ private fun ListItemWithIcon( color = TangemTheme.colors.text.tertiary, ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset), ) + if (accountTitleUM != null) { + AccountTitle( + accountTitleUM = accountTitleUM, + textStyle = TangemTheme.typography.caption2, + modifier = Modifier.padding(start = 4.dp), + ) + } } } } @@ -195,6 +212,7 @@ private fun ListItemWithIconPreview( ListItemWithIcon( title = config.title, subtitle = config.subtitle, + accountTitleUM = config.accountTitleUM, subtitleEndOffset = config.subtitleEndOffset, subtitleIconRes = config.iconRes, onClick = {}, @@ -206,6 +224,7 @@ private fun ListItemWithIconPreview( private data class ListItemWithIconPreviewConfig( val title: String, val subtitle: String, + val accountTitleUM: AccountTitleUM.Account? = null, val info: String? = null, val subtitleEndOffset: Int = 0, val iconRes: Int? = null, @@ -240,6 +259,15 @@ private class ListItemWithIconPreviewProvider : CollectionPreviewParameterProvid iconRes = R.drawable.ic_arrow_down_24, isLoading = true, ), + ListItemWithIconPreviewConfig( + title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + subtitle = "Wallet", + accountTitleUM = AccountTitleUM.Account( + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(), + prefixText = stringReference(StringsSigns.DOT), + ), + ), ), ) //endregion \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt index 2b1b2e0033..4b68b252ac 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt @@ -21,7 +21,9 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.containers.FooterContainer import com.tangem.core.ui.components.inputrow.InputRowRecipient import com.tangem.core.ui.extensions.* @@ -46,7 +48,6 @@ internal fun SendDestinationContent( if (state !is DestinationUM.Content) return val recipients = state.recent val wallets = state.wallets - val memoField = state.memoTextField val address = state.addressTextField val isValidating by remember(state.isValidating) { derivedStateOf { state.isValidating } } val isError by remember(address.isError) { derivedStateOf { address.isError } } @@ -64,18 +65,23 @@ internal fun SendDestinationContent( onQrCodeClick = clickIntents::onQrCodeScanClick, ) memoField( - memoField = memoField, + memoField = state.memoTextField, onMemoChange = clickIntents::onRecipientMemoValueChange, ) listHeaderItem( - titleRes = R.string.send_recipient_wallets_title, + titleRes = when (state.accountTitleUM) { + is AccountTitleUM.Account -> R.string.common_accounts + else -> R.string.send_recipient_wallets_title + }, + isLoading = state.accountTitleUM == null, isVisible = wallets.isNotEmpty() && wallets.first().isVisible && !state.isRecentHidden, isFirst = true, ) listItem( list = wallets, isLast = recipients.any { !it.isVisible }, - isBalanceHidden = isBalanceHidden, + accountTitleUM = state.accountTitleUM, + isBalanceHidden = false, isRecentHidden = state.isRecentHidden, onClick = { title -> clickIntents.onRecipientAddressValueChange( @@ -86,6 +92,7 @@ internal fun SendDestinationContent( ) listHeaderItem( titleRes = R.string.send_recent_transactions, + isLoading = state.accountTitleUM == null, isVisible = recipients.isNotEmpty() && recipients.first().isVisible && !state.isRecentHidden, isFirst = wallets.any { !it.isVisible }, ) @@ -180,7 +187,12 @@ private fun LazyListScope.memoField( } } -private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) { +private fun LazyListScope.listHeaderItem( + @StringRes titleRes: Int, + isVisible: Boolean, + isLoading: Boolean, + isFirst: Boolean, +) { item(key = titleRes) { AnimateRecentAppearance(isVisible) { val (topPadding, paddingFromTop) = if (isFirst) { @@ -189,10 +201,8 @@ private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Bo 0.dp to 8.dp } val topRadius = if (isFirst) 16.dp else 0.dp - Text( - text = stringResourceSafe(titleRes), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, + AnimatedContent( + targetState = isLoading, modifier = Modifier .fillMaxWidth() .padding(top = topPadding) @@ -209,7 +219,22 @@ private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Bo start = 12.dp, end = 12.dp, ), - ) + ) { currentIsLoading -> + if (currentIsLoading) { + Box { + TextShimmer( + style = TangemTheme.typography.subtitle2, + text = stringResourceSafe(titleRes), + ) + } + } else { + Text( + text = stringResourceSafe(titleRes), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + } + } } } } @@ -220,6 +245,7 @@ private fun LazyListScope.listItem( isBalanceHidden: Boolean, isRecentHidden: Boolean, onClick: (String) -> Unit, + accountTitleUM: AccountTitleUM? = null, ) { items( count = list.size, @@ -233,6 +259,7 @@ private fun LazyListScope.listItem( ListItemWithIcon( title = title, subtitle = item.subtitle.orMaskWithStars(isBalanceHidden).resolveReference(), + accountTitleUM = accountTitleUM, info = item.timestamp?.resolveReference(), subtitleEndOffset = item.subtitleEndOffset, subtitleIconRes = item.subtitleIconRes, diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 15b4768aaa..803e634a9f 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -201,16 +201,11 @@ class SendConfirmationNotificationsTransformerV2Test { return AmountState.Data( isPrimaryButtonEnabled = true, - isRedesignEnabled = false, - title = mockk(relaxed = true), - availableBalance = mockk(relaxed = true), + accountTitleUM = mockk(relaxed = true), availableBalanceCrypto = mockk(relaxed = true), availableBalanceFiat = mockk(relaxed = true), tokenName = mockk(relaxed = true), tokenIconState = mockk(relaxed = true), - segmentedButtonConfig = persistentListOf(), - selectedButton = 0, - isSegmentedButtonsEnabled = false, amountTextField = AmountFieldModel( value = "1.5", onValueChange = {}, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt index 131d64fb11..cdd95ace06 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt @@ -52,7 +52,9 @@ internal class StakingAnalyticSender( source = when (value.currentStep) { StakingStep.InitialInfo -> 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..17ba69396f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary @@ -46,10 +47,12 @@ internal class SetAmountDataTransformer( return prevState.copy( amountState = AmountStateConverter( clickIntents = clickIntents, - cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, - appCurrencyProvider = appCurrencyProvider, iconStateConverter = iconStateConverter, maxEnterAmount = maxEnterAmount, + appCurrency = appCurrencyProvider(), + cryptoCurrencyStatus = cryptoCurrencyStatusProvider(), + isBalanceHidden = false, + accountTitleUM = AccountTitleUM.Text(title), ).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..86c5a2e899 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -2,6 +2,7 @@ package com.tangem.features.staking.impl.presentation.state.transformers import com.tangem.blockchain.common.Blockchain import com.tangem.common.extensions.remove +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState @@ -229,10 +230,12 @@ internal class SetInitialDataStateTransformer( ) return AmountStateConverter( clickIntents = clickIntents, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - appCurrencyProvider = appCurrencyProvider, + cryptoCurrencyStatus = cryptoCurrencyStatus, + appCurrency = appCurrencyProvider(), iconStateConverter = iconStateConverter, maxEnterAmount = maxEnterAmount, + isBalanceHidden = false, + accountTitleUM = AccountTitleUM.Text(stringReference(userWalletProvider().name)), ).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/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts index fed1e814da..b64e7ab8d8 100644 --- a/features/swap-v2/impl/build.gradle.kts +++ b/features/swap-v2/impl/build.gradle.kts @@ -68,6 +68,8 @@ dependencies { implementation(projects.domain.notifications) implementation(projects.domain.feedback.models) implementation(projects.domain.feedback) + implementation(projects.domain.account) + implementation(projects.domain.account.status) /** Compose */ implementation(deps.compose.foundation) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt index ee40d193e5..a2f39e1550 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt @@ -2,6 +2,7 @@ package com.tangem.features.swap.v2.impl.amount import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -23,6 +24,8 @@ internal sealed class SwapAmountComponentParams { abstract val primaryCryptoCurrencyStatusFlow: StateFlow abstract val secondaryCryptoCurrency: CryptoCurrency? abstract val filterProviderTypes: List + abstract val accountFlow: StateFlow + abstract val isAccountModeFlow: StateFlow data class AmountParams( override val amountUM: SwapAmountUM, @@ -34,6 +37,8 @@ internal sealed class SwapAmountComponentParams { override val secondaryCryptoCurrency: CryptoCurrency?, override val filterProviderTypes: List = emptyList(), override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + override val accountFlow: StateFlow, + override val isAccountModeFlow: StateFlow, val title: TextReference, val callback: SwapAmountComponent.ModelCallback, val currentRoute: Flow, @@ -49,6 +54,8 @@ internal sealed class SwapAmountComponentParams { override val secondaryCryptoCurrency: CryptoCurrency?, override val filterProviderTypes: List = emptyList(), override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + override val accountFlow: StateFlow, + override val isAccountModeFlow: StateFlow, val blockClickEnableFlow: StateFlow, ) : SwapAmountComponentParams() } \ 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/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 8f43cade49..6d26842357 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -342,6 +342,8 @@ internal class SwapAmountModel @Inject constructor( appCurrency = appCurrency, swapDirection = swapDirection, clickIntents = this, + isAccountsMode = params.isAccountModeFlow.value, + account = params.accountFlow.value, ), ) }.launchIn(modelScope) @@ -367,6 +369,8 @@ internal class SwapAmountModel @Inject constructor( isBalanceHidden = params.isBalanceHidingFlow.value, showBestRateAnimation = showBestRateAnimation, isSingleWallet = isOnlyOneWallet, + isAccountsMode = params.isAccountModeFlow.value, + account = params.accountFlow.value, ), ) } @@ -384,38 +388,45 @@ internal class SwapAmountModel @Inject constructor( } private fun subscribeOnCryptoCurrencyStatusFlow() { - params.primaryCryptoCurrencyStatusFlow - .distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes - .onEach { primaryCurrencyStatus -> - val secondaryStatus = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus - initCurrencies( - primaryStatus = primaryCurrencyStatus, - secondaryStatus = secondaryStatus, + combine( + flow = params.primaryCryptoCurrencyStatusFlow.distinctUntilChanged { old, new -> + old.value.amount == new.value.amount + }, // Check only balance changes, + flow2 = params.accountFlow, + flow3 = params.isAccountModeFlow, + ) { primaryCurrencyStatus, account, isAccountsMode -> + val secondaryStatus = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus + initCurrencies( + primaryStatus = primaryCurrencyStatus, + secondaryStatus = secondaryStatus, + ) + if (secondaryStatus != null) { + uiState.transformerUpdate( + SwapAmountUpdateBalanceTransformer( + cryptoCurrencyStatus = primaryCurrencyStatus, + primaryMaximumAmountBoundary = primaryMaximumAmountBoundary, + primaryMinimumAmountBoundary = primaryMinimumAmountBoundary, + ), ) - if (secondaryStatus != null) { - uiState.transformerUpdate( - SwapAmountUpdateBalanceTransformer( - cryptoCurrencyStatus = primaryCurrencyStatus, - primaryMaximumAmountBoundary = primaryMaximumAmountBoundary, - primaryMinimumAmountBoundary = primaryMinimumAmountBoundary, - ), - ) - } else { - val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1 - uiState.transformerUpdate( - SwapAmountPrimaryReadyStateTransformer( - userWallet = userWallet, - primaryCryptoCurrencyStatus = primaryCurrencyStatus, - appCurrency = appCurrency, - swapDirection = swapDirection, - clickIntents = this, - isBalanceHidden = params.isBalanceHidingFlow.value, - showBestRateAnimation = showBestRateAnimation, - isSingleWallet = isOnlyOneWallet, - ), - ) - } - }.launchIn(modelScope) + } else { + val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1 + uiState.transformerUpdate( + SwapAmountPrimaryReadyStateTransformer( + userWallet = userWallet, + primaryCryptoCurrencyStatus = primaryCurrencyStatus, + appCurrency = appCurrency, + swapDirection = swapDirection, + clickIntents = this, + isBalanceHidden = params.isBalanceHidingFlow.value, + showBestRateAnimation = showBestRateAnimation, + isSingleWallet = isOnlyOneWallet, + isAccountsMode = isAccountsMode, + account = account, + ), + ) + } + }.flowOn(dispatchers.default) + .launchIn(modelScope) } private fun subscribeOnAmountUpdateTriggerUpdates() { @@ -523,6 +534,8 @@ internal class SwapAmountModel @Inject constructor( isBalanceHidden = params.isBalanceHidingFlow.value, showBestRateAnimation = showBestRateAnimation, isSingleWallet = isOnlyOneWallet, + isAccountsMode = params.isAccountModeFlow.value, + account = params.accountFlow.value, ), ) startLoadingQuotesTask(isSilentReload = false) 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..9dd4198d6b 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,8 @@ 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.AmountAccountConverter +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 @@ -11,6 +12,7 @@ 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.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection @@ -19,6 +21,7 @@ import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType import com.tangem.utils.StringsSigns.DOT +@Suppress("LongParameterList") internal class SwapAmountFieldConverter( private val swapDirection: SwapDirection, private val isBalanceHidden: Boolean, @@ -26,12 +29,19 @@ internal class SwapAmountFieldConverter( private val appCurrency: AppCurrency, private val clickIntents: AmountScreenClickIntents, private val isSingleWallet: Boolean, + private val isAccountsMode: Boolean, + private val account: Account.CryptoPortfolio?, ) { private val iconStateConverter = CryptoCurrencyToIconStateConverter() private val maxEnterAmountConverter = MaxEnterAmountConverter() fun convert(selectedType: SwapAmountType, cryptoCurrencyStatus: CryptoCurrencyStatus): SwapAmountFieldUM { + val walletTitle = if (isSingleWallet) { + resourceReference(R.string.send_from_title) + } else { + resourceReference(R.string.send_from_wallet_name, wrappedList(userWallet.name)) + } return SwapAmountFieldUM.Content( amountType = selectedType, title = stringReference(cryptoCurrencyStatus.currency.name), @@ -44,20 +54,25 @@ internal class SwapAmountFieldConverter( subtitleEllipsisRight = TextEllipsis.OffsetEnd(appCurrency.symbol.length), priceImpact = null, isClickEnabled = selectedType.isViewingField(), - amountField = AmountStateConverterV2( + amountField = AmountStateConverter( clickIntents = clickIntents, appCurrency = appCurrency, cryptoCurrencyStatus = cryptoCurrencyStatus, maxEnterAmount = maxEnterAmountConverter.convert(cryptoCurrencyStatus), iconStateConverter = iconStateConverter, isBalanceHidden = isBalanceHidden, + accountTitleUM = AmountAccountConverter( + isAccountsMode = isAccountsMode, + walletTitle = walletTitle, + prefixText = when { + selectedType.isEnteringField() -> resourceReference(R.string.common_from) + selectedType.isViewingField() -> resourceReference(R.string.common_to) + else -> TextReference.Companion.EMPTY + }, + ).convert(account), ).convert( AmountParameters( - title = if (isSingleWallet) { - resourceReference(R.string.send_from_title) - } else { - resourceReference(R.string.send_from_wallet_name, wrappedList(userWallet.name)) - }, + title = walletTitle, value = "", ), ), 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..3febeaf504 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 @@ -3,6 +3,7 @@ package com.tangem.features.swap.v2.impl.amount.model.transformers import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM @@ -11,6 +12,7 @@ import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter import com.tangem.utils.transformer.Transformer +@Suppress("LongParameterList") internal class SwapAmountBalanceHiddenTransformer( private val isBalanceHidden: Boolean, private val isSingleWallet: Boolean, @@ -18,6 +20,8 @@ internal class SwapAmountBalanceHiddenTransformer( private val appCurrency: AppCurrency, private val swapDirection: SwapDirection, private val clickIntents: AmountScreenClickIntents, + private val isAccountsMode: Boolean, + private val account: Account.CryptoPortfolio?, ) : Transformer { override fun transform(prevState: SwapAmountUM): SwapAmountUM { @@ -30,6 +34,8 @@ internal class SwapAmountBalanceHiddenTransformer( appCurrency = appCurrency, clickIntents = clickIntents, isSingleWallet = isSingleWallet, + isAccountsMode = isAccountsMode, + account = account, ) val recalculatedPrimary = amountFieldConverter.convert( @@ -46,9 +52,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/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt index b6ff17ca44..9d39c5e8f4 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPrimaryReadyStateTransformer.kt @@ -3,6 +3,7 @@ package com.tangem.features.swap.v2.impl.amount.model.transformers import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapCurrencies @@ -25,6 +26,8 @@ internal class SwapAmountPrimaryReadyStateTransformer( private val isBalanceHidden: Boolean, private val showBestRateAnimation: Boolean, private val isSingleWallet: Boolean, + private val isAccountsMode: Boolean, + private val account: Account.CryptoPortfolio?, ) : Transformer { private val amountFieldConverter = SwapAmountFieldConverter( @@ -34,6 +37,8 @@ internal class SwapAmountPrimaryReadyStateTransformer( appCurrency = appCurrency, clickIntents = clickIntents, isSingleWallet = isSingleWallet, + isAccountsMode = isAccountsMode, + account = account, ) override fun transform(prevState: SwapAmountUM): SwapAmountUM { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt index da85780483..7d4a258917 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt @@ -3,6 +3,7 @@ package com.tangem.features.swap.v2.impl.amount.model.transformers import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapCurrencies @@ -26,6 +27,8 @@ internal class SwapAmountSecondaryReadyStateTransformer( private val isBalanceHidden: Boolean, private val showBestRateAnimation: Boolean, private val isSingleWallet: Boolean, + private val isAccountsMode: Boolean, + private val account: Account.CryptoPortfolio?, ) : Transformer { private val amountFieldConverter = SwapAmountFieldConverter( @@ -35,6 +38,8 @@ internal class SwapAmountSecondaryReadyStateTransformer( appCurrency = appCurrency, clickIntents = clickIntents, isSingleWallet = isSingleWallet, + isAccountsMode = isAccountsMode, + account = account, ) override fun transform(prevState: SwapAmountUM): SwapAmountUM { 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..5fc6dc2696 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 @@ -22,10 +22,13 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter 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 androidx.constraintlayout.compose.ConstrainedLayoutReference import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayoutScope +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.ui.AmountBlockV2 import com.tangem.core.ui.extensions.TextReference @@ -126,8 +129,7 @@ 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, + accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.send_with_swap_recipient_amount_title)), availableBalanceCrypto = TextReference.EMPTY, ) ?: amountUM.secondaryAmount.amountField, isClickDisabled = true, @@ -232,10 +234,12 @@ private fun BoxScope.SwapDivider() { @Composable @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun SwapAmountBlockContent_Preview() { +private fun SwapAmountBlockContent_Preview( + @PreviewParameter(SwapAmountBlockContentPreviewProvider::class) params: SwapAmountUM, +) { TangemThemePreview { SwapAmountBlockContent( - amountUM = SwapAmountContentPreview.defaultState, + amountUM = params, isClickEnabled = true, onProviderSelectClick = {}, onInfoClick = {}, @@ -244,4 +248,12 @@ private fun SwapAmountBlockContent_Preview() { ) } } + +private class SwapAmountBlockContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SwapAmountContentPreview.defaultState, + SwapAmountContentPreview.defaultStateAccount, + ) +} // endregion \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt index a53021a6a3..91c6f69e30 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import com.tangem.common.ui.account.AccountTitle import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.ui.AmountFieldV2 import com.tangem.core.ui.components.SpacerH16 @@ -194,17 +195,14 @@ private fun SwapAmountEditBlock( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier.padding(top = 48.dp, bottom = 28.dp), ) { - if (amountFieldUM.amountField !is AmountState.Data) { - TextShimmer( - style = TangemTheme.typography.caption2, - modifier = Modifier.width(60.dp), - ) - } else { - Text( - text = (amountFieldUM.amountField as AmountState.Data).title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) + when (val amountFieldUM = amountFieldUM.amountField) { + !is AmountState.Data -> { + TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier.width(60.dp), + ) + } + else -> AccountTitle(amountFieldUM.accountTitleUM) } AmountFieldV2( amountUM = amountFieldUM.amountField, @@ -435,6 +433,7 @@ private class SwapAmountContentPreviewProvider : PreviewParameterProvider, val primaryCryptoCurrencyStatusFlow: StateFlow, val primaryFeePaidCurrencyStatusFlow: StateFlow, + val accountFlow: StateFlow, + val isAccountModeFlow: StateFlow, val callback: ModelCallback, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt index 811d403eac..357ffacf9c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -7,11 +7,15 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -50,7 +54,10 @@ internal class SendWithSwapModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val swapAlertFactory: SwapAlertFactory, + private val accountsFeatureToggles: AccountsFeatureToggles, paramsContainer: ParamsContainer, ) : Model(), SwapAmountComponent.ModelCallback, @@ -89,6 +96,12 @@ internal class SendWithSwapModel @Inject constructor( ), ) + val accountFlow: StateFlow + field = MutableStateFlow(null) + + val isAccountModeFlow: StateFlow + field = MutableStateFlow(false) + init { initUserWallet() initAppCurrency() @@ -192,36 +205,54 @@ internal class SendWithSwapModel @Inject constructor( val isSingleWalletWithToken = wallet is UserWallet.Cold && wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() - getCurrencyStatus( - cryptoCurrency = cryptoCurrency, - isSingleWalletWithToken = isSingleWalletWithToken, - isMultiCurrency = isMultiCurrency, - ).onEach { maybeCryptoCurrency -> - maybeCryptoCurrency.fold( - ifRight = { cryptoCurrencyStatus -> - primaryCryptoCurrencyStatusFlow.value = cryptoCurrencyStatus - primaryFeePaidCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = params.userWalletId, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ).getOrNull() ?: cryptoCurrencyStatus - }, - ifLeft = { error -> - swapAlertFactory.getGenericErrorState( - expressError = ExpressError.UnknownError, - onFailedTxEmailClick = { - modelScope.launch { - swapAlertFactory.onFailedTxEmailClick( - userWallet = userWallet, - cryptoCurrency = params.currency, - errorMessage = error.toString(), - ) - } - }, - popBack = ::onBackClick, - ) - }, - ) - }.launchIn(modelScope) + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase( + userWalletId = params.userWalletId, + currency = cryptoCurrency, + ).onEach { (account, cryptoCurrencyStatus) -> + accountFlow.value = account + isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync() + + primaryCryptoCurrencyStatusFlow.value = cryptoCurrencyStatus + primaryFeePaidCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = params.userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: cryptoCurrencyStatus + }.flowOn(dispatchers.default) + .launchIn(modelScope) + } else { + getCurrencyStatus( + cryptoCurrency = cryptoCurrency, + isSingleWalletWithToken = isSingleWalletWithToken, + isMultiCurrency = isMultiCurrency, + ).onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifRight = { cryptoCurrencyStatus -> + primaryCryptoCurrencyStatusFlow.value = cryptoCurrencyStatus + primaryFeePaidCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = params.userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: cryptoCurrencyStatus + }, + ifLeft = { error -> + swapAlertFactory.getGenericErrorState( + expressError = ExpressError.UnknownError, + onFailedTxEmailClick = { + modelScope.launch { + swapAlertFactory.onFailedTxEmailClick( + userWallet = userWallet, + cryptoCurrency = params.currency, + errorMessage = error.toString(), + ) + } + }, + popBack = ::onBackClick, + ) + }, + ) + }.flowOn(dispatchers.default) + .launchIn(modelScope) + } } private fun getCurrencyStatus( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index 4e50ff50f7..98710745ed 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.common.ui.navigationButtons.NavigationButton @@ -130,24 +132,24 @@ private fun SuccessContent(sendWithSwapUM: SendWithSwapUM, modifier: Modifier = @Composable private fun SwapAmountBlock(amountUM: SwapAmountUM.Content) { + val amountFieldUM = amountUM.primaryAmount.amountField as? AmountState.Data ?: return + AmountBlock( - title = resourceReference( - id = R.string.send_from_wallet_name, - formatArgs = wrappedList( - (amountUM.primaryAmount.amountField as? AmountState.Data)?.title - ?: TextReference.EMPTY, - ), - ), + accountTitleUM = amountFieldUM.accountTitleUM, amountFieldUM = amountUM.primaryAmount, ) AmountBlock( - title = resourceReference(R.string.send_with_swap_recipient_amount_success_title), + accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.send_with_swap_recipient_amount_success_title)), amountFieldUM = amountUM.secondaryAmount, ) } @Composable -private fun AmountBlock(title: TextReference, amountFieldUM: SwapAmountFieldUM, modifier: Modifier = Modifier) { +private fun AmountBlock( + accountTitleUM: AccountTitleUM, + amountFieldUM: SwapAmountFieldUM, + modifier: Modifier = Modifier, +) { val amountFieldData = amountFieldUM.amountField as? AmountState.Data ?: return val cryptoAmount = amountFieldData.amountTextField.cryptoAmount val fiatAmount = amountFieldData.amountTextField.fiatAmount @@ -158,11 +160,7 @@ private fun AmountBlock(title: TextReference, amountFieldUM: SwapAmountFieldUM, .background(TangemTheme.colors.background.action) .padding(12.dp), ) { - Text( - text = title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) + AccountTitle(accountTitleUM) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -312,6 +310,7 @@ private fun SendWithSwapSuccessContent_Preview() { isValidating = false, isInitialized = false, isRecentHidden = false, + accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.send_recipient_wallets_title)), ), feeSelectorUM = FeeSelectorUM.Content( fees = TransactionFee.Single( diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index d4fec5e8ed..638147c85c 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -7,6 +7,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -40,7 +41,7 @@ internal class DefaultSwapTransactionRepository( storeTransactionState( txId = transaction.txId, status = it, - refundTokenCurrency = null, + accountWithCurrency = null, ) } appPreferencesStore.editData { mutablePreferences -> @@ -181,7 +182,7 @@ internal class DefaultSwapTransactionRepository( override suspend fun storeTransactionState( txId: String, status: ExchangeStatusModel, - refundTokenCurrency: CryptoCurrency?, + accountWithCurrency: Pair?, ) { appPreferencesStore.editData { mutablePreferences -> val savedMap = mutablePreferences.getObjectMap( @@ -190,8 +191,8 @@ internal class DefaultSwapTransactionRepository( val updatesMap = savedMap.toMutableMap() updatesMap[txId] = status.copy( - refundTokensResponse = refundTokenCurrency?.let { - userTokensResponseFactory.createResponseToken(refundTokenCurrency) + refundTokensResponse = accountWithCurrency?.let { (accountId, currency) -> + userTokensResponseFactory.createResponseToken(currency, accountId) }, ) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index abef06ede4..d476f5ff9c 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -22,10 +22,12 @@ internal class SavedSwapTransactionListConverter( fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( - value.fromCryptoCurrency, + currency = value.fromCryptoCurrency, + accountId = null, ), toTokensResponse = userTokensResponseFactory.createResponseToken( - value.toCryptoCurrency, + currency = value.toCryptoCurrency, + accountId = null, ), transactions = value.transactions, ) @@ -82,8 +84,11 @@ internal class SavedSwapTransactionListConverter( userWalletId = userWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, - fromTokensResponse = userTokensResponseFactory.createResponseToken(fromCryptoCurrency), - toTokensResponse = userTokensResponseFactory.createResponseToken(toCryptoCurrency), + fromTokensResponse = userTokensResponseFactory.createResponseToken( + currency = fromCryptoCurrency, + accountId = null, + ), + toTokensResponse = userTokensResponseFactory.createResponseToken(currency = toCryptoCurrency, accountId = null), transactions = tokenTransactions, ) } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index f851de1028..e815518f1c 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -29,7 +30,11 @@ interface SwapTransactionRepository { txId: String, ) - suspend fun storeTransactionState(txId: String, status: ExchangeStatusModel, refundTokenCurrency: CryptoCurrency?) + suspend fun storeTransactionState( + txId: String, + status: ExchangeStatusModel, + accountWithCurrency: Pair? = null, + ) suspend fun storeLastSwappedCryptoCurrencyId(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID) 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/api/build.gradle.kts b/features/tangempay/details/api/build.gradle.kts index 7d64c9e77f..11df34c60d 100644 --- a/features/tangempay/details/api/build.gradle.kts +++ b/features/tangempay/details/api/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { /** Domain */ implementation(projects.domain.models) + implementation(projects.domain.visa.models) /** Compose */ implementation(deps.compose.runtime) diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index ab118672a6..428f063730 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -2,8 +2,9 @@ package com.tangem.features.tangempay.components import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.pay.TangemPayDetailsConfig interface TangemPayDetailsComponent : ComposableContentComponent { - data class Params(val customerWalletAddress: String, val cardNumberEnd: String) + data class Params(val config: TangemPayDetailsConfig) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 689a03be9f..cc2ac540ec 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { /** Features api */ implementation(projects.features.tangempay.details.api) implementation(projects.features.txhistory.api) + implementation(projects.features.tokenRecieve.api) /** Domain */ implementation(projects.domain.balanceHiding) @@ -36,6 +37,7 @@ dependencies { implementation(deps.compose.material3) implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) + implementation(deps.decompose.ext.compose) /** DI */ implementation(deps.hilt.android) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt index 0aa60f2cf3..adad4cfd19 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt @@ -4,13 +4,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.model.TangemPayDetailsNavigation import com.tangem.features.tangempay.ui.TangemPayDetailsScreen +import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,17 +26,26 @@ import dagger.assisted.AssistedInject internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : AppComponentContext by appComponentContext, TangemPayDetailsComponent { private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TangemPayDetailsNavigation.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( appComponentContext = child("txHistoryComponent"), - params = DefaultTangemPayTxHistoryComponent.Params(customerWalletAddress = params.customerWalletAddress), + params = DefaultTangemPayTxHistoryComponent.Params(customerWalletAddress = params.config.customerWalletAddress), ) @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() NavigationBar3ButtonsScrim() TangemPayDetailsScreen( @@ -36,6 +53,25 @@ internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( txHistoryComponent = txHistoryComponent, modifier = modifier, ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + navigation: TangemPayDetailsNavigation, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (navigation) { + is TangemPayDetailsNavigation.Error -> TangemPayErrorBottomSheetComponent( + appComponentContext = appComponentContext, + messageUM = navigation.messageUM, + onDismiss = model.bottomSheetNavigation::dismiss, + ) + is TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = navigation.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) } @AssistedFactory diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt new file mode 100644 index 0000000000..62e7a375f5 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent + +internal class TangemPayErrorBottomSheetComponent( + appComponentContext: AppComponentContext, + private val messageUM: MessageBottomSheetUMV2, + private val onDismiss: () -> Unit, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + onDismiss() + } + + @Composable + override fun BottomSheet() { + MessageBottomSheetV2(state = messageUM, onDismissRequest = ::dismiss) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/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/TangemPayDetailsErrorType.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsErrorType.kt new file mode 100644 index 0000000000..fa1fe5d982 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsErrorType.kt @@ -0,0 +1,5 @@ +package com.tangem.features.tangempay.entity + +internal enum class TangemPayDetailsErrorType { + Receive, +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt new file mode 100644 index 0000000000..0946f1aa37 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -0,0 +1,56 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.utils.CardDetailsFormatUtil +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.persistentListOf + +private const val CARD_NUMBER_SART_DIGITS_COUNT = 12 +private const val DATE_PART_LENGTH = 2 +private const val CVV_LENGTH = 3 + +internal class TangemPayDetailsStateFactory( + private val cardNumberEnd: String, + private val onBack: () -> Unit, + private val onRefresh: (ShowRefreshState) -> Unit, + private val onReceive: () -> Unit, + private val onReveal: () -> Unit, + private val onCopy: (String) -> Unit, +) { + + private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT) + private val dateMasked = maskedBlock(DATE_PART_LENGTH) + private val cvvMasked = maskedBlock(CVV_LENGTH) + + fun getInitialState() = TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = onBack, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = onRefresh), + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( + actionButtons = persistentListOf( + ActionButtonConfig( + text = resourceReference(id = R.string.common_receive), + iconResId = R.drawable.ic_arrow_down_24, + onClick = onReceive, + ), + ), + ), + cardDetailsUM = TangemPayCardDetailsUM( + number = CardDetailsFormatUtil.formatCardNumber(cardNumber = "$cardStartMasked$cardNumberEnd"), + expiry = CardDetailsFormatUtil.formatDate(month = dateMasked, year = dateMasked), + cvv = cvvMasked, + buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text), + onClick = onReveal, + onCopy = onCopy, + isHidden = true, + ), + isBalanceHidden = false, + + ) + + private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count) +} \ No newline at end of file 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/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 0760c76fda..da92a7606c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -1,35 +1,48 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.toArgb +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.TokenReceiveType +import com.tangem.domain.pay.DataForReceiveFactory import com.tangem.domain.pay.repository.CardDetailsRepository import com.tangem.features.tangempay.components.TangemPayDetailsComponent 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.TangemPayDetailsErrorType +import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.model.transformers.* +import com.tangem.features.tangempay.utils.TangemPayErrorMessageFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.transformer.update -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject +/** + * Custom token name and icon url. Will be used only for F&F. + */ +private const val TOKEN_NAME = "USDC" +private const val TOKEN_ICON_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/usd-coin.png" + +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayDetailsModel @Inject constructor( @@ -37,23 +50,67 @@ internal class TangemPayDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val cardDetailsRepository: CardDetailsRepository, + private val dataForReceiveFactory: DataForReceiveFactory, private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, ) : Model() { private val params: TangemPayDetailsComponent.Params = paramsContainer.require() + private val stateFactory = TangemPayDetailsStateFactory( + cardNumberEnd = params.config.cardNumberEnd, + onBack = router::pop, + onRefresh = ::onRefreshSwipe, + onReceive = ::onClickReceive, + onReveal = ::revealCardDetails, + onCopy = ::copyData, + ) + val uiState: StateFlow - field = MutableStateFlow(getInitialState()) + field = MutableStateFlow(stateFactory.getInitialState()) private val refreshStateJobHolder = JobHolder() private val fetchBalanceJobHolder = JobHolder() private val revealCardDetailsJobHolder = JobHolder() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + init { fetchBalance() } + private fun onClickReceive() { + val depositAddress = params.config.depositAddress + if (depositAddress == null) { + showError() + } else { + dataForReceiveFactory.getDataForReceive(depositAddress = depositAddress, chainId = params.config.chainId) + .onRight { + val config = TokenReceiveConfig( + shouldShowWarning = false, + cryptoCurrency = it.currency, + userWalletId = it.walletId, + showMemoDisclaimer = false, + receiveAddress = it.receiveAddress, + type = TokenReceiveType.Custom( + tokenName = TOKEN_NAME, + tokenIconUrl = TOKEN_ICON_URL, + fallbackTint = TangemColorPalette.Black.toArgb(), + fallbackBackground = TangemColorPalette.Meadow.toArgb(), + ), + ) + bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config)) + } + .onLeft { + val messageUM = TangemPayErrorMessageFactory.createError( + type = TangemPayDetailsErrorType.Receive, + onDismiss = bottomSheetNavigation::dismiss, + ) + bottomSheetNavigation.activate(TangemPayDetailsNavigation.Error(messageUM)) + } + } + } + private fun fetchBalance(): Job { return modelScope.launch { val result = cardDetailsRepository.getCardBalance() @@ -63,25 +120,13 @@ internal class TangemPayDetailsModel @Inject constructor( private fun onRefreshSwipe(refreshState: ShowRefreshState) { modelScope.launch { + hideCardDetails() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value)) fetchBalance().join() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) }.saveIn(refreshStateJobHolder) } - private fun getInitialState() = TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = router::pop, items = null), - pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = ::onRefreshSwipe), - balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()), - cardDetailsUM = TangemPayCardDetailsUM(), - isBalanceHidden = false, - ).let { - DetailsHiddenStateTransformer( - onClickReveal = ::revealCardDetails, - cardNumberEnd = params.cardNumberEnd, - ).transform(it) - } - private fun revealCardDetails() { modelScope.launch { uiState.update( @@ -93,17 +138,11 @@ internal class TangemPayDetailsModel @Inject constructor( transformer = DetailsRevealedStateTransformer( details = it, onClickHide = ::hideCardDetails, - onClickCopy = ::copyData, ), ) } .onLeft { - uiState.update( - transformer = DetailsHiddenStateTransformer( - onClickReveal = ::revealCardDetails, - cardNumberEnd = params.cardNumberEnd, - ), - ) + uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) showError() } }.saveIn(revealCardDetailsJobHolder) @@ -112,12 +151,7 @@ internal class TangemPayDetailsModel @Inject constructor( private fun hideCardDetails() { modelScope.launch { revealCardDetailsJobHolder.cancel() - uiState.update( - transformer = DetailsHiddenStateTransformer( - onClickReveal = ::revealCardDetails, - cardNumberEnd = params.cardNumberEnd, - ), - ) + uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory)) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsNavigation.kt new file mode 100644 index 0000000000..bcf24b8829 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsNavigation.kt @@ -0,0 +1,17 @@ +package com.tangem.features.tangempay.model + +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.domain.models.TokenReceiveConfig +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayDetailsNavigation { + + data class Receive( + val config: TokenReceiveConfig, + ) : TangemPayDetailsNavigation() + + data class Error( + val messageUM: MessageBottomSheetUMV2, + ) : TangemPayDetailsNavigation() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/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/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index 14627ed23d..61b1413c22 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -26,7 +26,7 @@ internal class DetailsBalanceTransformer( fiatBalance = getBalanceText(balance.value), // TODO [REDACTED_TASK_KEY]: Add crypto balance when the BFF is ready cryptoBalance = "", - actionButtons = persistentListOf(), + actionButtons = prevState.balanceBlockState.actionButtons, ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt index 35025ce2a3..218ff38231 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt @@ -1,38 +1,14 @@ package com.tangem.features.tangempay.model.transformers -import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.features.tangempay.utils.CardDetailsFormatUtil -import com.tangem.utils.StringsSigns import com.tangem.utils.transformer.Transformer -private const val CARD_NUMBER_SART_DIGITS_COUNT = 12 -private const val DATE_PART_LENGTH = 2 -private const val CVV_LENGTH = 3 - internal class DetailsHiddenStateTransformer( - private val onClickReveal: () -> Unit, - private val cardNumberEnd: String, + private val stateFactory: TangemPayDetailsStateFactory, ) : Transformer { - private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT) - private val dateMasked = maskedBlock(DATE_PART_LENGTH) - private val cvvMasked = maskedBlock(CVV_LENGTH) - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val cardDetailsUM = TangemPayCardDetailsUM( - number = CardDetailsFormatUtil.formatCardNumber(cardNumber = "$cardStartMasked$cardNumberEnd"), - expiry = CardDetailsFormatUtil.formatDate(month = dateMasked, year = dateMasked), - cvv = cvvMasked, - buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text), - onClick = onClickReveal, - onCopy = {}, - isHidden = true, - ) - return prevState.copy(cardDetailsUM = cardDetailsUM) + return prevState.copy(cardDetailsUM = stateFactory.getInitialState().cardDetailsUM) } - - private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt index 45ba18f069..477f82b511 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt @@ -11,7 +11,6 @@ import com.tangem.utils.transformer.Transformer internal class DetailsRevealedStateTransformer( private val details: TangemPayCardDetails, private val onClickHide: (() -> Unit), - private val onClickCopy: ((String) -> Unit), ) : Transformer { override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { @@ -21,7 +20,7 @@ internal class DetailsRevealedStateTransformer( cvv = details.cvv, onClick = onClickHide, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - onCopy = onClickCopy, + onCopy = prevState.cardDetailsUM.onCopy, isHidden = false, ) return prevState.copy(cardDetailsUM = cardDetailsUM) 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/TangemPayErrorMessageFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayErrorMessageFactory.kt new file mode 100644 index 0000000000..2f5ce481ea --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayErrorMessageFactory.kt @@ -0,0 +1,28 @@ +package com.tangem.features.tangempay.utils + +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.message.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType + +internal object TangemPayErrorMessageFactory { + + fun createError(type: TangemPayDetailsErrorType, onDismiss: () -> Unit): MessageBottomSheetUMV2 { + return when (type) { + TangemPayDetailsErrorType.Receive -> messageBottomSheetUM { + infoBlock { + icon(R.drawable.img_attention_20) { + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention + } + title = TextReference.Res(R.string.tangempay_card_details_receive_error_title) + body = TextReference.Res(R.string.tangempay_card_details_receive_error_description) + } + primaryButton { + text = resourceReference(R.string.common_got_it) + onClick { onDismiss() } + } + } + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/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/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt index 21a0d5aad5..02d68f4f73 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt @@ -177,6 +177,7 @@ private fun PreviewFeatureTogglesScreen() { environments = persistentSetOf( ApiEnvironment.DEV.name, ApiEnvironment.DEV_2.name, + ApiEnvironment.DEV_3.name, ApiEnvironment.STAGE.name, ApiEnvironment.MOCK.name, ApiEnvironment.PROD.name, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/utils/ApiEnvironmentComparator.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/utils/ApiEnvironmentComparator.kt index cdf172d0c4..ac81103c06 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/utils/ApiEnvironmentComparator.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/utils/ApiEnvironmentComparator.kt @@ -14,9 +14,10 @@ internal object ApiEnvironmentComparator : Comparator { when (it) { ApiEnvironment.DEV -> 0 ApiEnvironment.DEV_2 -> 1 - ApiEnvironment.STAGE -> 2 - ApiEnvironment.MOCK -> 3 - ApiEnvironment.PROD -> 4 + ApiEnvironment.DEV_3 -> 2 + ApiEnvironment.STAGE -> 3 + ApiEnvironment.MOCK -> 4 + ApiEnvironment.PROD -> 5 } } diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt index 24d68dcfea..3dd7bc3947 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt @@ -77,6 +77,7 @@ internal class DefaultTokenReceiveComponent @AssistedInject constructor( is TokenReceiveRoutes.QrCode -> TokenReceiveQrCodeComponent( appComponentContext = appComponentContext, params = TokenReceiveQrCodeComponent.TokenReceiveQrCodeParams( + type = model.params.config.type, cryptoCurrency = model.params.config.cryptoCurrency, address = model.state.value.addresses.find { it.value == config.address } ?: error( "Address has to be there", diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt index 02123bbf3a..6780fd2740 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.TokenReceiveType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource import com.tangem.features.tokenreceive.entity.ReceiveAddress @@ -36,5 +37,6 @@ internal class TokenReceiveQrCodeComponent( val address: ReceiveAddress, val callback: TokenReceiveQrCodeModelCallback, val onDismiss: () -> Unit, + val type: TokenReceiveType = TokenReceiveType.Default, ) } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt index 02595a5ca4..2d014adf53 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt @@ -1,13 +1,17 @@ package com.tangem.features.tokenreceive.entity +import androidx.compose.ui.graphics.Color import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.TokenReceiveType import com.tangem.domain.models.TokenReceiveNotification import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.ens.EnsAddress @@ -25,6 +29,7 @@ internal class TokenReceiveStateFactory( private val cryptoCurrency: CryptoCurrency, private val addresses: List, private val tokenReceiveNotification: List, + private val tokenReceiveType: TokenReceiveType, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -35,7 +40,10 @@ internal class TokenReceiveStateFactory( addresses = addresses, cryptoCurrency = cryptoCurrency, ), - iconState = iconStateConverter.convert(cryptoCurrency), + iconState = when (tokenReceiveType) { + is TokenReceiveType.Default -> iconStateConverter.convert(cryptoCurrency) + is TokenReceiveType.Custom -> getCustomCurrencyIconState(tokenReceiveType) + }, network = cryptoCurrency.network.name, isEnsResultLoading = false, notificationConfigs = getNotifications( @@ -159,4 +167,13 @@ internal class TokenReceiveStateFactory( } } } + + private fun getCustomCurrencyIconState(type: TokenReceiveType.Custom) = CurrencyIconState.TokenIcon( + url = type.tokenIconUrl, + topBadgeIconResId = cryptoCurrency.networkIconResId, + fallbackTint = Color(type.fallbackTint), + fallbackBackground = Color(type.fallbackBackground), + isGrayscale = false, + shouldShowCustomBadge = false, + ) } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveModel.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveModel.kt index 4229545563..91e67ea518 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveModel.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveModel.kt @@ -9,6 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.domain.models.Asset +import com.tangem.domain.models.TokenReceiveType import com.tangem.domain.models.network.Network import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource @@ -48,6 +49,7 @@ internal class TokenReceiveModel @Inject constructor( addresses = params.config.receiveAddress, tokenReceiveNotification = params.config.tokenReceiveNotification, currentStateProvider = Provider { state.value }, + tokenReceiveType = params.config.type, ) } @@ -96,9 +98,12 @@ internal class TokenReceiveModel @Inject constructor( } internal fun getTokenName(): String { - return when (val asset = params.config.asset) { - Asset.Currency -> params.config.cryptoCurrency.symbol - Asset.NFT -> asset.name + return when (val type = params.config.type) { + is TokenReceiveType.Default -> when (val asset = params.config.asset) { + Asset.Currency -> params.config.cryptoCurrency.symbol + Asset.NFT -> asset.name + } + is TokenReceiveType.Custom -> type.tokenName } } diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveQrCodeModel.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveQrCodeModel.kt index 6f10eea53a..43ab4296aa 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveQrCodeModel.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveQrCodeModel.kt @@ -5,6 +5,8 @@ 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.domain.models.TokenReceiveType.Default +import com.tangem.domain.models.TokenReceiveType.Custom import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource import com.tangem.features.tokenreceive.component.TokenReceiveQrCodeComponent import com.tangem.features.tokenreceive.ui.state.QrCodeUM @@ -27,7 +29,12 @@ internal class TokenReceiveQrCodeModel @Inject constructor( QrCodeUM( network = params.cryptoCurrency.network.name, addressValue = params.address.value, - addressName = TextReference.Str("${params.cryptoCurrency.name} (${params.cryptoCurrency.symbol})"), + addressName = when (params.type) { + is Default -> + TextReference.Str("${params.cryptoCurrency.name} (${params.cryptoCurrency.symbol})") + is Custom -> + TextReference.Str(params.type.tokenName) + }, onCopyClick = { params.callback.onCopyClick( address = params.address, 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/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 4945e2727a..c02aa188d0 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -57,35 +57,36 @@ dependencies { implementation(projects.core.decompose) implementation(projects.common.ui) + implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) /** Domain modules */ + implementation(projects.domain.account.status) implementation(projects.domain.appCurrency) implementation(projects.domain.appCurrency.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) implementation(projects.domain.card) implementation(projects.domain.demo) implementation(projects.domain.legacy) - implementation(projects.libs.blockchainSdk) - implementation(projects.domain.models) - implementation(projects.domain.settings) - implementation(projects.domain.tokens) - implementation(projects.domain.tokens.models) - implementation(projects.domain.txhistory) - implementation(projects.domain.txhistory.models) - implementation(projects.domain.wallets) - implementation(projects.domain.wallets.models) - implementation(projects.domain.balanceHiding) - implementation(projects.domain.balanceHiding.models) - implementation(projects.domain.transaction) - implementation(projects.domain.transaction.models) - implementation(projects.domain.staking) implementation(projects.domain.markets.models) + implementation(projects.domain.models) + implementation(projects.domain.notifications.models) implementation(projects.domain.onramp) implementation(projects.domain.onramp.models) implementation(projects.domain.promo) implementation(projects.domain.promo.models) implementation(projects.domain.quotes) - implementation(projects.domain.notifications.models) + implementation(projects.domain.settings) + implementation(projects.domain.staking) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) + implementation(projects.domain.txhistory) + implementation(projects.domain.txhistory.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) /** Temp dependency to swap domain */ implementation(projects.features.swap.domain) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 8397d73477..46d7427763 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import arrow.core.merge +import arrow.core.right import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss @@ -31,17 +32,18 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase -import com.tangem.domain.models.ReceiveAddressModel -import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.TokenReceiveNotification +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -86,6 +88,7 @@ import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.isZero @@ -139,12 +142,14 @@ internal class TokenDetailsModel @Inject constructor( private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener, private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, - private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, - private val getEnsNameUseCase: GetEnsNameUseCase, + private val receiveAddressesFactory: ReceiveAddressesFactory, private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase, private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase, private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, ) : Model(), TokenDetailsClickIntents, YieldSupplyDepositedWarningComponent.ModelCallback { private val params = paramsContainer.require() @@ -162,6 +167,7 @@ internal class TokenDetailsModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null + private var account: Account.CryptoPortfolio? = null private var isBalanceLoadedEventSent = false private var expressTxStatusTaskScheduler = SingleTaskScheduler>() @@ -232,15 +238,26 @@ internal class TokenDetailsModel @Inject constructor( private fun initButtons() { // we need also init buttons before start all loading to avoid buttons blocking modelScope.launch { - val currentCryptoCurrencyStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrency.id, - isSingleWalletWithTokens = false, - ).getOrNull() - currentCryptoCurrencyStatus?.let { - cryptoCurrencyStatus = it - updateButtons(it) - updateWarnings(it) + val currentCryptoCurrencyStatus = if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCryptoCurrencyStatusUseCase.invokeSync( + userWalletId = userWalletId, + currency = cryptoCurrency, + ) + .onSome { account = it.account } + .getOrNull() + ?.status + } else { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId = userWalletId, + cryptoCurrencyId = cryptoCurrency.id, + isSingleWalletWithTokens = false, + ).getOrNull() + } + + currentCryptoCurrencyStatus?.let { status -> + cryptoCurrencyStatus = status + updateButtons(currencyStatus = status) + updateWarnings(cryptoCurrencyStatus = status) } } } @@ -312,12 +329,18 @@ internal class TokenDetailsModel @Inject constructor( } private fun subscribeOnCurrencyStatusUpdates() { - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = userWalletId, - currencyId = cryptoCurrency.id, - isSingleWalletWithTokens = userWallet is UserWallet.Cold && - userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), - ) + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .onEach { account = it.account } + .map { it.status.right() } + } else { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = userWallet is UserWallet.Cold && + userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + } .distinctUntilChanged() .onEach { maybeCurrencyStatus -> internalUiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus) @@ -557,7 +580,7 @@ internal class TokenDetailsModel @Inject constructor( } override fun onReceiveClick(unavailabilityReason: ScenarioUnavailabilityReason) { - val networkAddress = cryptoCurrencyStatus?.value?.networkAddress ?: return + cryptoCurrencyStatus?.value?.networkAddress ?: return analyticsEventsHandler.send( TokenScreenAnalyticsEvent.ButtonWithParams.ButtonReceive( @@ -693,7 +716,18 @@ internal class TokenDetailsModel @Inject constructor( override fun onHideConfirmed() { modelScope.launch { - removeCurrencyUseCase.invoke(userWalletId, cryptoCurrency) + if (accountsFeatureToggles.isFeatureEnabled) { + val accountId = account?.accountId + + if (accountId == null) { + Timber.e("Account ID is null, cannot hide currency ${cryptoCurrency.id}") + return@launch + } + + saveCryptoCurrenciesUseCase(accountId = accountId, remove = cryptoCurrency) + } else { + removeCurrencyUseCase(userWalletId, cryptoCurrency) + } .onLeft { Timber.e(it) } .onRight { router.popBackStack() } } @@ -1020,6 +1054,12 @@ internal class TokenDetailsModel @Inject constructor( } override fun onYieldInfoClick() { + analyticsEventsHandler.send( + YieldSupplyAnalytics.EarnedFundsInfo( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + ), + ) bottomSheetNavigation.activate( configuration = TokenDetailsBottomSheetConfig.YieldSupplyWarning( cryptoCurrency = cryptoCurrency, @@ -1058,34 +1098,10 @@ internal class TokenDetailsModel @Inject constructor( .launchIn(modelScope) } - private suspend fun configureReceiveAddresses(addresses: NetworkAddress): TokenDetailsBottomSheetConfig { - val ensName = getEnsNameUseCase.invoke( - userWalletId = userWalletId, - network = cryptoCurrency.network, - address = addresses.defaultAddress.value, - ) - - val receiveAddresses = buildList { - ensName?.let { ens -> - add( - ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, - value = ens, - ), - ) - } - addresses.availableAddresses.map { address -> - add( - ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy - }, - value = address.value, - ), - ) - } - } + private suspend fun configureReceiveAddresses( + cryptoCurrencyStatus: CryptoCurrencyStatus?, + ): TokenDetailsBottomSheetConfig? { + cryptoCurrencyStatus ?: return null val notifications = buildList { if (isActiveYieldSupply()) { @@ -1099,16 +1115,13 @@ internal class TokenDetailsModel @Inject constructor( } } - return TokenDetailsBottomSheetConfig.Receive( - TokenReceiveConfig( - shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(), - cryptoCurrency = cryptoCurrency, - userWalletId = userWalletId, - showMemoDisclaimer = cryptoCurrency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, - tokenReceiveNotification = notifications, - receiveAddress = receiveAddresses, - ), - ) + val receiveConfig = receiveAddressesFactory.create( + status = cryptoCurrencyStatus, + userWalletId = userWalletId, + notifications = notifications, + ) ?: return null + + return TokenDetailsBottomSheetConfig.Receive(receiveConfig) } private fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) { @@ -1186,9 +1199,8 @@ internal class TokenDetailsModel @Inject constructor( val networkAddress = cryptoCurrencyStatus?.value?.networkAddress ?: return if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { modelScope.launch { - bottomSheetNavigation.activate( - configuration = configureReceiveAddresses(addresses = networkAddress), - ) + configureReceiveAddresses(cryptoCurrencyStatus = cryptoCurrencyStatus) + ?.let { bottomSheetNavigation.activate(it) } } } else { analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ReceiveScreenOpened(cryptoCurrency.symbol)) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 27a03a1006..0ba2ad9f35 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -4,13 +4,17 @@ import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.swap.ExpressAnalyticsStatus import com.tangem.datasource.local.swap.SwapTransactionStatusStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent -import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* @@ -27,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map +import timber.log.Timber @Suppress("LongParameterList") internal class ExchangeStatusFactory @AssistedInject constructor( @@ -34,6 +39,9 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val swapRepository: SwapRepository, private val quotesRepository: QuotesRepository, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, @Assisted private val clickIntents: TokenDetailsClickIntents, @@ -111,9 +119,41 @@ internal class ExchangeStatusFactory @AssistedInject constructor( ifRight = { statusModel -> sendStatusUpdateAnalytics(statusModel, provider) - val refundTokenCurrency = addRefundCurrencyIfNeeded(statusModel, provider.type) + val accountId = if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + ) + .map { it.account.accountId } + .getOrNull() + } else { + null + } - swapTransactionRepository.storeTransactionState(txId, statusModel, refundTokenCurrency) + val refundTokenCurrency = if (accountsFeatureToggles.isFeatureEnabled) { + if (accountId != null) { + addRefundCurrencyIfNeededNew( + accountId = accountId, + status = statusModel, + type = provider.type, + ) + } else { + Timber.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}") + null + } + } else { + addRefundCurrencyIfNeededLegacy(status = statusModel, type = provider.type) + } + + swapTransactionRepository.storeTransactionState( + txId = txId, + status = statusModel, + accountWithCurrency = if (refundTokenCurrency != null) { + Pair(accountId, refundTokenCurrency) + } else { + null + }, + ) statusModel.copy(refundCurrency = refundTokenCurrency) }, ) @@ -135,7 +175,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( /** * For now do it only for dex-bridge provider */ - private suspend fun addRefundCurrencyIfNeeded( + private suspend fun addRefundCurrencyIfNeededLegacy( status: ExchangeStatusModel?, type: ExchangeProviderType, ): CryptoCurrency? { @@ -153,6 +193,27 @@ internal class ExchangeStatusFactory @AssistedInject constructor( return null } + private suspend fun addRefundCurrencyIfNeededNew( + accountId: AccountId, + status: ExchangeStatusModel?, + type: ExchangeProviderType, + ): CryptoCurrency? { + status ?: return null + if (type != ExchangeProviderType.DEX_BRIDGE) return null + val refundNetwork = status.refundNetwork + val refundContractAddress = status.refundContractAddress + + if (refundNetwork == null || refundContractAddress == null) return null + + return saveCryptoCurrenciesUseCase.add( + accountId = accountId, + contractAddress = refundContractAddress, + networkId = refundNetwork, + ) + .onLeft(Timber::e) + .getOrNull() + } + private fun getExchangeStatusState( savedTransactions: List?, quoteStatuses: Set, 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/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt index 42738f4e11..06e59f01b9 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow interface UserWalletImageFetcher { + fun allWallets(size: ArtworkSize): Flow> fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow fun walletImage(cardDTO: CardDTO, size: ArtworkSize): Flow fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow 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 84cea60e9f..ebf62b0e39 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 @@ -361,10 +361,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, ), ) @@ -373,9 +373,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..e0eeaf7bd6 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 @@ -22,6 +22,9 @@ import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.core.lce.Lce @@ -29,8 +32,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 import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -43,12 +44,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,7 +53,8 @@ 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.transaction.usecase.GetEnsNameUseCase +import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -70,7 +67,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 @@ -78,13 +74,14 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import timber.log.Timber import java.math.BigDecimal import javax.inject.Inject interface WalletCurrencyActionsClickIntents { fun onSendClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) @@ -92,32 +89,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 +136,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, @@ -152,16 +148,20 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val appRouter: AppRouter, private val rampStateManager: RampStateManager, private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, - private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase, - private val getEnsNameUseCase: GetEnsNameUseCase, + private val receiveAddressesFactory: ReceiveAddressesFactory, private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase, private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase, + private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase, + private val removeCurrencyUseCase: RemoveCurrencyUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { override fun onSendClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) { @@ -185,27 +185,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 +272,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 +294,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 +302,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 +340,30 @@ 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) + if (accountsFeatureToggles.isFeatureEnabled) { + val accountId = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = userWalletId, + currency = cryptoCurrencyStatus.currency, + ) + .map { it.account.accountId } + .getOrNull() + + if (accountId == null) { + Timber.e("Account ID is null, cannot hide currency ${cryptoCurrencyStatus.currency.id}") + return@launch + } + + saveCryptoCurrenciesUseCase(accountId = accountId, remove = cryptoCurrencyStatus.currency) + } else { + removeCurrencyUseCase(userWalletId, cryptoCurrencyStatus.currency) + } .fold( ifLeft = { walletEventSender.send( @@ -366,7 +371,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) }, ifRight = { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = portfolioId.userWalletId)) + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) }, ) } @@ -399,7 +404,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } override fun onBuyClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) { @@ -415,7 +420,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( appRouter.push( AppRoute.Onramp( - portfolioId = portfolioId, + userWalletId = userWalletId, currency = cryptoCurrencyStatus.currency, source = OnrampSource.TOKEN_LONG_TAP, ), @@ -424,7 +429,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( override fun onSwapClick( cryptoCurrencyStatus: CryptoCurrencyStatus, - portfolioId: PortfolioId, + userWalletId: UserWalletId, unavailabilityReason: ScenarioUnavailabilityReason, ) { analyticsEventHandler.send( @@ -447,12 +452,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 +498,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 +524,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 { @@ -720,44 +729,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } private suspend fun configureReceiveAddresses(cryptoCurrencyStatus: CryptoCurrencyStatus): TokenReceiveConfig? { - val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return null val userWalletId = stateHolder.getSelectedWalletId() - - val ensName = getEnsNameUseCase.invoke( + return receiveAddressesFactory.create( + status = cryptoCurrencyStatus, userWalletId = userWalletId, - network = cryptoCurrencyStatus.currency.network, - address = networkAddress.defaultAddress.value, - ) - - val receiveAddresses = buildList { - ensName?.let { ens -> - add( - ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, - value = ens, - ), - ) - } - networkAddress.availableAddresses.map { address -> - add( - ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy - }, - value = address.value, - ), - ) - } - } - - return TokenReceiveConfig( - shouldShowWarning = cryptoCurrencyStatus.currency.name !in getViewedTokenReceiveWarningUseCase(), - cryptoCurrency = cryptoCurrencyStatus.currency, - userWalletId = userWalletId, - showMemoDisclaimer = cryptoCurrencyStatus.currency.network.transactionExtrasType != Network - .TransactionExtrasType.NONE, - receiveAddress = receiveAddresses, ) } @@ -766,21 +741,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 +783,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 25b487ec43..739be22276 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 @@ -361,7 +361,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..8213172d17 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,17 +3,16 @@ 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 import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.redux.StateDialog import com.tangem.domain.tokens.model.details.TokenAction @@ -67,12 +66,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 +86,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)) } @@ -114,8 +109,8 @@ internal class DefaultWalletRouter @Inject constructor( router.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding)) } - override fun openTangemPayDetails(customerWalletAddress: String, cardNumberEnd: String) { - router.push(AppRoute.TangemPayDetails(customerWalletAddress, cardNumberEnd)) + override fun openTangemPayDetails(config: TangemPayDetailsConfig) { + router.push(AppRoute.TangemPayDetails(config)) } override fun openYieldSupplyBottomSheet( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 2ff741c7b5..d869ca8b84 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,13 +2,13 @@ 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 import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.tokens.model.details.TokenAction import com.tangem.feature.wallet.navigation.WalletRoute import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig @@ -42,7 +42,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 +50,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) @@ -63,7 +60,7 @@ internal interface InnerWalletRouter { fun openTangemPayOnboarding() - fun openTangemPayDetails(customerWalletAddress: String, cardNumberEnd: String) + fun openTangemPayDetails(config: TangemPayDetailsConfig) /** Open BS abput yield supply active and all money deposited in AAVE */ fun openYieldSupplyBottomSheet( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt index 6e08203c24..135b2c2f6c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt @@ -73,6 +73,13 @@ sealed class WalletScreenAnalyticsEvent { data object ScreenOpened : MainScreen(event = "Screen opened") + class WalletSelected(val isImported: Boolean) : MainScreen( + event = "Wallet Selected", + params = mapOf( + "Wallet Type" to if (isImported) "Seed Phrase" else "Seedless", + ), + ) + class EnableBiometrics(state: AnalyticsParam.OnOffState) : MainScreen( event = "Enable Biometric", params = mapOf("State" to state.value), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/SelectedWalletAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/SelectedWalletAnalyticsSender.kt index 605a356fe0..90e94e979a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/SelectedWalletAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/SelectedWalletAnalyticsSender.kt @@ -29,6 +29,14 @@ internal class SelectedWalletAnalyticsSender @Inject constructor( * */ private fun getEvent(userWallet: UserWallet): AnalyticsEvent? = when { userWallet.isLocked -> WalletScreenAnalyticsEvent.MainScreen.WalletUnlock - else -> null + + else -> WalletScreenAnalyticsEvent.MainScreen.WalletSelected(userWallet.isImported()) + } + + private fun UserWallet.isImported(): Boolean { + return when (this) { + is UserWallet.Cold -> isImported + is UserWallet.Hot -> true + } } } \ No newline at end of file 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/TangemPayInitialStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt new file mode 100644 index 0000000000..a83f41f964 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt @@ -0,0 +1,68 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +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.TangemPayDetailsConfig +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.UNKNOWN +import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState +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 + +/** + * Hardcode Polygon chain id only for F&F. + * Later chain id will be fetched from BFF. + */ +private const val POLYGON_CHAIN_ID = 137 + +internal class TangemPayInitialStateTransformer( + private val value: MainScreenCustomerInfo? = null, + private val onClickIssue: () -> Unit = {}, + private val onClickKyc: () -> Unit = {}, + private val openDetails: (config: TangemPayDetailsConfig) -> Unit = {}, +) : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState { + val tangemPayState = createInitialState() + return prevState.copy(tangemPayState = tangemPayState) + } + + private fun createInitialState(): TangemPayState { + val cardInfo = value?.info?.cardInfo + return when { + value == null -> TangemPayState.Empty + !value.info.isKycApproved -> createKycInProgressState(onClickKyc) + cardInfo != null -> getCardInfoState(cardInfo) + value.orderStatus == UNKNOWN || value.orderStatus == CANCELED -> createIssueAvailableState(onClickIssue) + else -> createIssueProgressState() + } + } + + private fun getCardInfoState(cardInfo: CardInfo): TangemPayState = TangemPayState.Card( + lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"), + balanceText = TextReference.Str(getBalanceText(cardInfo)), + onClick = { + openDetails( + TangemPayDetailsConfig( + customerWalletAddress = cardInfo.customerWalletAddress, + cardNumberEnd = cardInfo.lastFourDigits, + chainId = POLYGON_CHAIN_ID, + depositAddress = cardInfo.depositAddress, + ), + ) + }, + ) + + private fun getBalanceText(cardInfo: CardInfo): String { + val currency = Currency.getInstance(cardInfo.currencyCode) + return cardInfo.balance.format { + fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol) + } + } +} \ No newline at end of file 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/TangemPayStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt deleted file mode 100644 index 9e367377ba..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt +++ /dev/null @@ -1,79 +0,0 @@ -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.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 java.util.Currency - -internal class TangemPayStateTransformer( - private val value: MainScreenCustomerInfo? = null, - private val onIssueOrderClick: () -> Unit = {}, - private val onContinueKycClick: () -> 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() - } - return prevState.copy(tangemPayState = tangemPayState) - } - - private fun createInitialState(): TangemPayState { - val cardInfo = value?.info?.cardInfo - return when { - value == null -> TangemPayState.Empty - !value.info.isKycApproved() -> createKycInProgressState(onContinueKycClick) - cardInfo != null -> getCardInfoState(cardInfo) - value.orderStatus == NOT_ISSUED || value.orderStatus == CANCELED -> createIssueState() - 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)), - onClick = { openDetails(cardInfo.customerWalletAddress, cardInfo.lastFourDigits) }, - ) - - private fun getBalanceText(cardInfo: CardInfo): String { - val currency = Currency.getInstance(cardInfo.currencyCode) - return cardInfo.balance.format { - fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol) - } - } -} \ 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/DefaultUserWalletImageFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt index f6f11319dd..d27eb2dc8e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt @@ -9,6 +9,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.operations.attestation.ArtworkSize import kotlinx.coroutines.flow.* @@ -17,12 +18,21 @@ import javax.inject.Inject class DefaultUserWalletImageFetcher @Inject constructor( private val getCardImageUseCase: GetCardImageUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, + private val getWalletsUseCase: GetWalletsUseCase, private val artworkUMConverter: ArtworkUMConverter, ) : UserWalletImageFetcher { private val smallCache = MutableStateFlow(mapOf()) private val largeCache = MutableStateFlow(mapOf()) + override fun allWallets(size: ArtworkSize): Flow> = + getWalletsUseCase.invoke() + .map { list -> list.map { it.walletId } } + .distinctUntilChanged() + .map { list -> list.map { walletId -> walletImage(walletId, size).map { image -> walletId to image } } } + .flatMapLatest { flows -> combine(flows) { it.toMap() } } + .distinctUntilChanged() + override fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow = when (wallet) { is UserWallet.Cold -> walletImage(wallet.scanResponse.card, size) is UserWallet.Hot -> flowOf(UserWalletItemUM.ImageState.MobileWallet) @@ -31,11 +41,8 @@ class DefaultUserWalletImageFetcher @Inject constructor( override fun walletsImage( wallets: Collection, size: ArtworkSize, - ): Flow> = wallets - .map { userWallet -> walletImage(userWallet, size).map { imageState -> userWallet.walletId to imageState } } - .merge() - .runningFold(mapOf()) { map, newState -> map.plus(newState) } - .filter { it.size >= wallets.size } // prevent spam, waiting full map + ): Flow> = allWallets(size) + .map { allWallets -> allWallets.filter { (walletId, _) -> wallets.any { it.walletId == walletId } } } .distinctUntilChanged() override fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow = flow { 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/api/build.gradle.kts b/features/yield-supply/api/build.gradle.kts index 7fbd6e9fd1..162dfe30ca 100644 --- a/features/yield-supply/api/build.gradle.kts +++ b/features/yield-supply/api/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { /** Core */ implementation(projects.core.decompose) implementation(projects.core.ui) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) /** Domain */ implementation(projects.domain.models) diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/analytics/YieldSupplyAnalytics.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/analytics/YieldSupplyAnalytics.kt new file mode 100644 index 0000000000..faddabab9e --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/analytics/YieldSupplyAnalytics.kt @@ -0,0 +1,179 @@ +package com.tangem.features.yield.supply.api.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_DESCRIPTION +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM + +sealed class YieldSupplyAnalytics( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = "Earning", event = event, params = params) { + + data class EarningScreenInfoOpened( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Earning Screen Info Opened", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class StartEarningScreen( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Start Earning Screen", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class ButtonStartEarning( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Button - Start Earning", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class ButtonStopEarning( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Button - Stop Earning", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data object ButtonFeePolicy : YieldSupplyAnalytics( + event = "Button - Fee Policy", + ) + + data object EarnInProgressScreen : YieldSupplyAnalytics( + event = "Earn In Progress Screen", + ) + + data object FundsEarned : YieldSupplyAnalytics( + event = "Funds Earned", + ) + + data class FundsWithdrawn( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Funds Withdrawn", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class EarnedFundsInfo( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Earned Funds Info", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class NoticeNotEnoughFee( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Notice - Not Enough Fee", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class NoticeApproveNeeded( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Notice - Approve Needed", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class ApprovalAction( + val token: String, + val blockchain: String, + val action: Action, + ) : YieldSupplyAnalytics( + event = "Approval Action", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ACTION to action.value, + ), + ) + + data class NoticeHighNetworkFee( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Notice - High Network Fee", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class EarnErrors( + val action: Action, + val errorDescription: String?, + ) : YieldSupplyAnalytics( + event = "Earn Errors", + params = buildMap { + ACTION to action + ERROR_DESCRIPTION to errorDescription.orEmpty() + }, + ) + + data object ApyChartViewed : YieldSupplyAnalytics( + event = "APY Chart", + ) + + data class NoticeCommissionTooHigh( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Notice - Commission Is Too High", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + data class NoticeNotEnoughMinAmount( + val token: String, + val blockchain: String, + ) : YieldSupplyAnalytics( + event = "Notice - Not Enough Min Amount", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) + + enum class Action(val value: String) { + Approve("Approve"), + Stop("Stop"), + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index 02c2af1cb6..e79c976bd4 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.core.navigation) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) /** Compose */ implementation(tangemDeps.vico.core) 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..ffac0fef60 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 { @@ -15,6 +16,7 @@ internal sealed class YieldSupplyFeeUM { val feeValue: TextReference, val currentNetworkFeeValue: TextReference, val maxNetworkFeeValue: TextReference, + val minAmountFeeValue: TextReference, ) : YieldSupplyFeeUM() } @@ -27,4 +29,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/common/formatter/YieldSupplyMinAmountFormatter.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/formatter/YieldSupplyMinAmountFormatter.kt new file mode 100644 index 0000000000..08c48d9cb0 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/formatter/YieldSupplyMinAmountFormatter.kt @@ -0,0 +1,25 @@ +package com.tangem.features.yield.supply.impl.common.formatter + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.StringsSigns +import java.math.BigDecimal + +internal class YieldSupplyMinAmountFormatter( + private val feeCryptoCurrency: CryptoCurrency, + private val appCurrency: AppCurrency, +) { + + operator fun invoke(feeValue: BigDecimal, fiatRate: BigDecimal?): TextReference { + val cryptoFee = feeValue.format { crypto(feeCryptoCurrency) } + val fiatFeeValue = fiatRate?.let(feeValue::multiply) + val fiatFee = fiatFeeValue.format { fiat(appCurrency.code, appCurrency.symbol) } + + return stringReference(cryptoFee + " ${StringsSigns.DOT} " + fiatFee) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/ui/YieldSupplyActionContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/ui/YieldSupplyActionContent.kt index aea87550b9..03437a23fe 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/ui/YieldSupplyActionContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/ui/YieldSupplyActionContent.kt @@ -141,6 +141,7 @@ private class YieldSupplyActionContentPreviewProvider : PreviewParameterProvider feeValue = stringReference("0.00020 ETH • \$0.99"), currentNetworkFeeValue = stringReference("1.45 USDT • \$1.45"), maxNetworkFeeValue = stringReference("8.50 USDT • \$8.50"), + minAmountFeeValue = stringReference("50 USDT • \$50"), ), isPrimaryButtonEnabled = false, isTransactionSending = false, 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..0895525f5e 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 @@ -4,24 +4,31 @@ import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRoute.YieldSupplyPromo import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.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.api.analytics.YieldSupplyAnalytics 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 +36,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 @@ -37,6 +45,7 @@ import kotlin.properties.Delegates internal class YieldSupplyModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventsHandler: AnalyticsEventHandler, private val appRouter: AppRouter, private val getUserWalletUseCase: GetUserWalletUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, @@ -44,12 +53,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 +79,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 +109,7 @@ internal class YieldSupplyModel @Inject constructor( maybeCryptoCurrency.fold( ifRight = { cryptoCurrencyStatus -> cryptoCurrencyStatusFlow.update { cryptoCurrencyStatus } - onDataLoaded(cryptoCurrencyStatus) + onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus) }, ifLeft = { Timber.w(it.toString()) @@ -102,23 +125,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 +167,85 @@ 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 + if (!yieldSupplyStatus.isAllowedToSpend) { + analyticsEventsHandler.send( + YieldSupplyAnalytics.NoticeApproveNeeded( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + ), + ) + } + 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}%"), + ), + 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/promo/model/YieldSupplyPromoModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt index 9dbc5ecdd9..4a3d0411df 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt @@ -3,30 +3,48 @@ package com.tangem.features.yield.supply.impl.promo.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.promo.YieldSupplyPromoConfig import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM +import com.tangem.utils.TangemBlogUrlBuilder.YIELD_SUPPLY_HOW_IT_WORKS_URL import com.tangem.utils.coroutines.CoroutineDispatcherProvider import javax.inject.Inject @ModelScoped internal class YieldSupplyPromoModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val analytics: AnalyticsEventHandler, private val urlOpener: UrlOpener, private val appRouter: AppRouter, ) : Model(), YieldSupplyPromoClickIntents { + val params: YieldSupplyPromoComponent.Params = paramsContainer.require() + val uiState: YieldSupplyPromoUM = YieldSupplyPromoUM( tosLink = "https://tangem.com/terms-of-service/", // TODO replace with real link policyLink = "https://tangem.com/privacy-policy/", // TODO replace with real link title = resourceReference(R.string.yield_module_promo_screen_title, wrappedList("5.3")), ) + init { + analytics.send( + YieldSupplyAnalytics.EarningScreenInfoOpened( + token = params.currency.symbol, + blockchain = params.currency.network.name, + ), + ) + } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() override fun onBackClick() { @@ -34,6 +52,7 @@ internal class YieldSupplyPromoModel @Inject constructor( } override fun onApyInfoClick() { + analytics.send(YieldSupplyAnalytics.ApyChartViewed) bottomSheetNavigation.activate(YieldSupplyPromoConfig.Apy) } @@ -42,7 +61,7 @@ internal class YieldSupplyPromoModel @Inject constructor( } override fun onHowItWorksClick() { - urlOpener.openUrl("https://tangem.com/") // TODO replace with real link + urlOpener.openUrl(YIELD_SUPPLY_HOW_IT_WORKS_URL) } override fun onStartEarningClick() { 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..7b1e55f50a 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,6 @@ internal data class YieldSupplyActiveContentUM( val subtitle: TextReference, val subtitleLink: TextReference, val notificationUM: NotificationUM?, + val apy: TextReference? = null, + val minAmount: TextReference?, ) \ 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..f54b17d1f9 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 @@ -1,35 +1,53 @@ package com.tangem.features.yield.supply.impl.subcomponents.active.model +import arrow.core.getOrElse import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.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.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +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.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics +import com.tangem.features.yield.supply.impl.common.formatter.YieldSupplyMinAmountFormatter 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.StringsSigns.DASH_SIGN import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class YieldSupplyActiveModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + analyticsHandler: AnalyticsEventHandler, private val yieldSupplyGetProtocolBalanceUseCase: YieldSupplyGetProtocolBalanceUseCase, + private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, + private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, ) : Model() { private val params: YieldSupplyActiveComponent.Params = paramsContainer.require() private val cryptoCurrencyStatusFlow = params.cryptoCurrencyStatusFlow private val cryptoCurrency = cryptoCurrencyStatusFlow.value.currency + private var appCurrency = AppCurrency.Default val uiState: StateFlow field = MutableStateFlow( @@ -43,13 +61,21 @@ internal class YieldSupplyActiveModel @Inject constructor( ), subtitleLink = resourceReference(R.string.common_read_more), notificationUM = null, + minAmount = null, ), ) init { + analyticsHandler.send( + YieldSupplyAnalytics.EarningScreenInfoOpened( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + ), + ) subscribeOnCurrencyUpdates() modelScope.launch(dispatchers.default) { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } val protocolBalance = yieldSupplyGetProtocolBalanceUseCase( userWalletId = params.userWallet.walletId, cryptoCurrency = cryptoCurrency, @@ -87,6 +113,9 @@ internal class YieldSupplyActiveModel @Inject constructor( null } + loadApy() + loadMinAmount() + uiState.update { it.copy( notificationUM = approvalNotification, @@ -104,6 +133,42 @@ 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 fun loadMinAmount() { + modelScope.launch(dispatchers.default) { + yieldSupplyMinAmountUseCase( + params.userWallet, + cryptoCurrencyStatusFlow.value, + ).onRight { minAmount -> + val minAmountTextReference = YieldSupplyMinAmountFormatter( + cryptoCurrencyStatusFlow.value.currency, + appCurrency, + ).invoke(minAmount, cryptoCurrencyStatusFlow.value.value.fiatRate) + uiState.update { + it.copy(minAmount = minAmountTextReference) + } + }.onLeft { + uiState.update { + it.copy(minAmount = TextReference.Str(DASH_SIGN)) + } + } + } + } + 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..a20183fb10 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,18 +56,9 @@ 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) AnimatedVisibility(state.notificationUM != null) { val wrappedNotification = remember(this) { requireNotNull(state.notificationUM) } @@ -73,6 +68,48 @@ internal fun YieldSupplyActiveContent( containerColor = TangemTheme.colors.background.action, ) } + + YieldSupplyActiveMyFunds(state = state, isBalanceHidden = isBalanceHidden) + } +} + +@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, + ) + } + } + } } } @@ -127,6 +164,15 @@ private fun YieldSupplyActiveMyFunds(state: YieldSupplyActiveContentUM, isBalanc info = state.availableBalance, isBalanceHidden = isBalanceHidden, ) + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + InfoRow( + title = resourceReference(R.string.yield_module_fee_policy_sheet_min_amount_title), + info = state.minAmount, + isBalanceHidden = false, + ) } } @@ -193,7 +239,11 @@ private fun YieldSupplyActiveBottomSheet_Preview( @PreviewParameter(YieldSupplyActiveBottomSheetPreviewProvider::class) params: YieldSupplyActiveContentUM, ) { TangemThemePreview { - YieldSupplyActiveContent(params, true) + YieldSupplyActiveContent( + state = params, + isBalanceHidden = true, + chartComponent = ComposableContentComponent.EMPTY, + ) } } @@ -210,6 +260,8 @@ private class YieldSupplyActiveBottomSheetPreviewProvider : PreviewParameterProv ), subtitleLink = resourceReference(R.string.common_read_more), notificationUM = NotificationUM.Error.InvalidAmount, + apy = stringReference("5,14%"), + minAmount = stringReference("50 USDT"), ), ) } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt index 0e7f24f5a7..5a7ac6bd0a 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt @@ -1,6 +1,9 @@ package com.tangem.features.yield.supply.impl.subcomponents.approve.model import arrow.core.getOrElse +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -19,6 +22,7 @@ import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetContractAddressUseCase +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM @@ -44,6 +48,7 @@ import javax.inject.Inject internal class YieldSupplyApproveModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, paramsContainer: ParamsContainer, + private val analyticsEventHandler: AnalyticsEventHandler, private val urlOpener: UrlOpener, private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @@ -138,6 +143,11 @@ internal class YieldSupplyApproveModel @Inject constructor( ) }, ifRight = { + analyticsEventHandler.send(YieldSupplyAnalytics.ApprovalAction( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + action = YieldSupplyAnalytics.Action.Approve, + )) params.callback.onTransactionSent() }, ) @@ -194,40 +204,47 @@ internal class YieldSupplyApproveModel @Inject constructor( } }, ifRight = { fee -> - val feeCryptoValue = fee.normal.amount.value + applyFee(fee, approvalTransitionData) + }, + ) + } - val feeFiatValue = feeCryptoCurrencyStatus.value.fiatRate?.let { rate -> - feeCryptoValue?.multiply(rate) - } - val cryptoFee = feeCryptoValue.format { crypto(feeCryptoCurrencyStatus.currency) } - val fiatFee = feeFiatValue.format { fiat(appCurrency.code, appCurrency.symbol) } + private suspend fun applyFee(transactionFee: TransactionFee, approvalTransitionData: TransactionData.Uncompiled) { + val feeCryptoValue = transactionFee.normal.amount.value - uiState.update { - if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { - it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) - } else { - it.copy( - isPrimaryButtonEnabled = true, - yieldSupplyFeeUM = YieldSupplyFeeUM.Content( - transactionDataList = persistentListOf(approvalTransitionData.copy(fee = fee.normal)), - feeValue = combinedReference( - stringReference(cryptoFee), - stringReference(" $DOT "), - stringReference(fiatFee), - ), - currentNetworkFeeValue = TextReference.EMPTY, - maxNetworkFeeValue = TextReference.EMPTY, - ), - ) - } - } - yieldSupplyNotificationsUpdateTrigger.triggerUpdate( - data = YieldSupplyNotificationData( - feeValue = feeCryptoValue, - feeError = null, + val feeFiatValue = feeCryptoCurrencyStatus.value.fiatRate?.let { rate -> + feeCryptoValue?.multiply(rate) + } + val cryptoFee = feeCryptoValue.format { crypto(feeCryptoCurrencyStatus.currency) } + val fiatFee = feeFiatValue.format { fiat(appCurrency.code, appCurrency.symbol) } + + uiState.update { + if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { + it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) + } else { + it.copy( + isPrimaryButtonEnabled = true, + yieldSupplyFeeUM = YieldSupplyFeeUM.Content( + transactionDataList = persistentListOf( + approvalTransitionData.copy(fee = transactionFee.normal), + ), + feeValue = combinedReference( + stringReference(cryptoFee), + stringReference(" $DOT "), + stringReference(fiatFee), + ), + currentNetworkFeeValue = TextReference.EMPTY, + maxNetworkFeeValue = TextReference.EMPTY, + minAmountFeeValue = TextReference.EMPTY, ), ) - }, + } + } + yieldSupplyNotificationsUpdateTrigger.triggerUpdate( + data = YieldSupplyNotificationData( + feeValue = feeCryptoValue, + feeError = null, + ), ) } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/feepolicy/ui/YieldSupplyFeePolicyContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/feepolicy/ui/YieldSupplyFeePolicyContent.kt index b90e1b09b4..b9fc7b1d23 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/feepolicy/ui/YieldSupplyFeePolicyContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/feepolicy/ui/YieldSupplyFeePolicyContent.kt @@ -29,6 +29,7 @@ import com.tangem.features.yield.supply.impl.common.ui.YieldSupplyFeeRow import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.persistentListOf +@Suppress("LongMethod") @Composable internal fun YieldSupplyFeePolicyContent( yieldSupplyFeeUM: YieldSupplyFeeUM, @@ -61,6 +62,28 @@ internal fun YieldSupplyFeePolicyContent( modifier = Modifier.padding(horizontal = 16.dp), ) SpacerH24() + FooterContainer( + footer = resourceReference( + id = R.string.yield_module_fee_policy_sheet_min_amount_note, + formatArgs = wrappedList(networkName), + ), + paddingValues = PaddingValues( + top = 8.dp, + start = 12.dp, + end = 12.dp, + ), + ) { + val minAmount = when (yieldSupplyFeeUM) { + is YieldSupplyFeeUM.Content -> yieldSupplyFeeUM.minAmountFeeValue + YieldSupplyFeeUM.Error -> stringReference(StringsSigns.DASH_SIGN) + YieldSupplyFeeUM.Loading -> null + } + YieldSupplyFeeRow( + title = resourceReference(R.string.yield_module_fee_policy_sheet_min_amount_title), + value = minAmount, + ) + } + SpacerH16() FooterContainer( footer = resourceReference( id = R.string.yield_module_fee_policy_sheet_current_fee_note, @@ -101,6 +124,18 @@ internal fun YieldSupplyFeePolicyContent( value = maxFee, ) } + Text( + text = stringResourceSafe(R.string.yield_module_fee_policy_tangem_service_fee_title), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .fillMaxWidth() + .padding( + top = 8.dp, + start = 12.dp, + end = 12.dp, + ), + ) } } @@ -116,11 +151,11 @@ private fun YieldSupplyFeePolicyContent_Preview() { feeValue = stringReference("0.0001 ETH • \$1.45"), maxNetworkFeeValue = stringReference("8.50 USDT • \$8.50"), currentNetworkFeeValue = stringReference("1.45 USDT • \$1.45"), + minAmountFeeValue = stringReference("50 USDT • \$50"), ), tokenSymbol = "USDT", networkName = "Ethereum", modifier = Modifier.background(TangemTheme.colors.background.primary), - ) } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt index 464eaaec8b..77907f4839 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt @@ -5,11 +5,13 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsComponent import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsUpdateListener import com.tangem.lib.crypto.BlockchainUtils @@ -25,6 +27,7 @@ import javax.inject.Inject internal class YieldSupplyNotificationsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, private val appRouter: AppRouter, private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, private val yieldSupplyNotificationsUpdateListener: YieldSupplyNotificationsUpdateListener, @@ -73,6 +76,15 @@ internal class YieldSupplyNotificationsModel @Inject constructor( ) } + if (notifications.any { it is NotificationUM.Error.TokenExceedsBalance }) { + analyticsEventHandler.send( + YieldSupplyAnalytics.NoticeNotEnoughFee( + token = cryptoCurrencyStatus.currency.symbol, + blockchain = cryptoCurrencyStatus.currency.network.name, + ), + ) + } + uiState.update { notifications.toPersistentList() } yieldSupplyNotificationsUpdateListener.callbackHasError(notifications.any()) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningComponent.kt index 3d02037617..1783ee53c8 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/YieldSupplyStartEarningComponent.kt @@ -117,6 +117,7 @@ internal class YieldSupplyStartEarningComponent( onClick = model::onClick, enabled = state.isPrimaryButtonEnabled, iconResId = icon, + showProgress = state.isTransactionSending, modifier = Modifier .fillMaxWidth() .padding(16.dp), diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt index b2b2db7f41..d6de1559ac 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.yield.supply.impl.subcomponents.startearning.model +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -8,6 +9,7 @@ import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIco import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM import com.tangem.features.yield.supply.impl.subcomponents.feepolicy.YieldSupplyFeePolicyComponent @@ -24,6 +26,7 @@ import javax.inject.Inject internal class YieldSupplyStartEarningEntryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, + private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, ) : Model(), YieldSupplyStartEarningComponent.ModelCallback, YieldSupplyFeePolicyComponent.ModelCallback { @@ -55,6 +58,9 @@ internal class YieldSupplyStartEarningEntryModel @Inject constructor( } override fun onFeePolicyClick() { + analyticsEventHandler.send( + YieldSupplyAnalytics.ButtonFeePolicy, + ) router.push(YieldSupplyStartEarningRoute.FeePolicy) } 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..f7390118e6 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 @@ -2,24 +2,32 @@ package com.tangem.features.yield.supply.impl.subcomponents.startearning.model import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionSender +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.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 import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.transaction.error.GetFeeError 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.YieldSupplyMinAmountUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM @@ -33,6 +41,7 @@ import com.tangem.features.yield.supply.impl.subcomponents.startearning.model.tr import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.update +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber @@ -45,6 +54,7 @@ import kotlin.properties.Delegates internal class YieldSupplyStartEarningModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, paramsContainer: ParamsContainer, + private val analytics: AnalyticsEventHandler, private val getUserWalletUseCase: GetUserWalletUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, @@ -55,11 +65,15 @@ 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, + private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() private val cryptoCurrency = params.cryptoCurrency + private var minAmount: BigDecimal by Delegates.notNull() var userWallet: UserWallet by Delegates.notNull() val cryptoCurrencyStatusFlow: StateFlow @@ -99,6 +113,12 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private var appCurrency = AppCurrency.Default init { + analytics.send( + YieldSupplyAnalytics.StartEarningScreen( + token = params.cryptoCurrency.symbol, + blockchain = params.cryptoCurrency.network.name, + ), + ) modelScope.launch { appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } subscribeOnCurrencyStatusUpdates() @@ -106,17 +126,51 @@ internal class YieldSupplyStartEarningModel @Inject constructor( } } + private suspend fun calculateMinAmount(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus) { + yieldSupplyMinAmountUseCase(userWallet, cryptoCurrencyStatus).onRight { + minAmount = it + }.onLeft { + minAmount = BigDecimal.ZERO + } + } + + 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 + uiState.update { + it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) + } + + 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, - ).getOrNull() ?: return + maxNetworkFee = maxFee, + ).getOrNull() - uiState.update { - it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) + if (transactionListData == null) { + uiState.update { + it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Error) + } + yieldSupplyNotificationsUpdateTrigger.triggerUpdate( + data = YieldSupplyNotificationData( + feeValue = null, + feeError = GetFeeError.UnknownError, + ), + ) + return } yieldSupplyEstimateEnterFeeUseCase.invoke( @@ -146,7 +200,8 @@ internal class YieldSupplyStartEarningModel @Inject constructor( appCurrency = appCurrency, updatedTransactionList = updatedTransactionList, feeValue = feeSum, - maxNetworkFee = MAX_NETWORK_FEE, + maxNetworkFee = maxFee, + minAmount = minAmount, ), ) yieldSupplyNotificationsUpdateTrigger.triggerUpdate( @@ -161,6 +216,12 @@ internal class YieldSupplyStartEarningModel @Inject constructor( fun onClick() { val yieldSupplyFeeUM = uiState.value.yieldSupplyFeeUM as? YieldSupplyFeeUM.Content ?: return + analytics.send( + YieldSupplyAnalytics.ButtonStartEarning( + token = params.cryptoCurrency.symbol, + blockchain = params.cryptoCurrency.network.name, + ), + ) uiState.update(YieldSupplyTransactionInProgressTransformer) modelScope.launch(dispatchers.default) { @@ -173,6 +234,10 @@ internal class YieldSupplyStartEarningModel @Inject constructor( ifLeft = { error -> Timber.e(error.toString()) uiState.update(YieldSupplyTransactionReadyTransformer) + analytics.send(YieldSupplyAnalytics.EarnErrors( + action = YieldSupplyAnalytics.Action.Approve, + errorDescription = error.getAnalyticsDescription(), + )) yieldSupplyAlertFactory.getSendTransactionErrorState( error = error, popBack = params.callback::onBackClick, @@ -188,7 +253,14 @@ internal class YieldSupplyStartEarningModel @Inject constructor( ) }, ifRight = { - fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id) + yieldSupplyActivateUseCase(cryptoCurrency) + modelScope.launch(NonCancellable) { + fetchCurrencyStatusUseCase( + userWalletId = userWallet.walletId, + cryptoCurrency.id, + ) + } + analytics.send(YieldSupplyAnalytics.FundsEarned) modelScope.launch { params.callback.onTransactionSent() } @@ -256,6 +328,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( feeCryptoCurrencyStatusFlow.update { feeCurrencyStatus } modelScope.launch { + calculateMinAmount(userWallet, currencyStatus) onLoadFee() } } @@ -274,8 +347,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/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformer.kt index 0e792d8529..5e0ba9a4a9 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformer.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformer.kt @@ -10,12 +10,14 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import com.tangem.features.yield.supply.impl.common.formatter.YieldSupplyMinAmountFormatter import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal import java.math.RoundingMode +@Suppress("LongParameterList") internal class YieldSupplyStartEarningFeeContentTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, @@ -23,6 +25,7 @@ internal class YieldSupplyStartEarningFeeContentTransformer( private val updatedTransactionList: List, private val feeValue: BigDecimal, private val maxNetworkFee: BigDecimal, + private val minAmount: BigDecimal, ) : Transformer { override fun transform(prevState: YieldSupplyActionUM): YieldSupplyActionUM { val cryptoCurrency = cryptoCurrencyStatus.currency @@ -46,6 +49,11 @@ internal class YieldSupplyStartEarningFeeContentTransformer( } val maxFiatFee = maxFiatFeeValue.format { fiat(appCurrency.code, appCurrency.symbol) } + val minAmountTextReference = YieldSupplyMinAmountFormatter( + cryptoCurrency, + appCurrency, + ).invoke(minAmount, cryptoCurrencyStatus.value.fiatRate) + return if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { prevState.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) } else { @@ -67,6 +75,7 @@ internal class YieldSupplyStartEarningFeeContentTransformer( stringReference(" $DOT "), stringReference(maxFiatFee), ), + minAmountFeeValue = minAmountTextReference, ), ) } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/YieldSupplyStopEarningComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/YieldSupplyStopEarningComponent.kt index 0db25f5017..2379649d45 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/YieldSupplyStopEarningComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/YieldSupplyStopEarningComponent.kt @@ -92,6 +92,7 @@ internal class YieldSupplyStopEarningComponent( onClick = model::onClick, iconResId = walletInterationIcon(params.userWallet), enabled = state.isPrimaryButtonEnabled, + showProgress = state.isTransactionSending, modifier = Modifier .fillMaxWidth() .padding(16.dp), 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..a6f111df3a 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 @@ -1,6 +1,7 @@ package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,11 +13,14 @@ 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.tokens.FetchCurrencyStatusUseCase 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.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM @@ -31,6 +35,7 @@ import com.tangem.utils.TangemBlogUrlBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.update +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber @@ -41,6 +46,7 @@ import javax.inject.Inject internal class YieldSupplyStopEarningModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, paramsContainer: ParamsContainer, + private val analytics: AnalyticsEventHandler, private val getFeeUseCase: GetFeeUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, @@ -49,6 +55,8 @@ internal class YieldSupplyStopEarningModel @Inject constructor( private val urlOpener: UrlOpener, private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, + private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, + private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() @@ -108,6 +116,12 @@ internal class YieldSupplyStopEarningModel @Inject constructor( fun onClick() { val yieldSupplyFeeUM = uiState.value.yieldSupplyFeeUM as? YieldSupplyFeeUM.Content ?: return + analytics.send( + YieldSupplyAnalytics.ButtonStopEarning( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + ), + ) uiState.update(YieldSupplyTransactionInProgressTransformer) modelScope.launch(dispatchers.default) { @@ -134,6 +148,16 @@ internal class YieldSupplyStopEarningModel @Inject constructor( ) }, ifRight = { + analytics.send( + YieldSupplyAnalytics.FundsWithdrawn( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + ), + ) + yieldSupplyDeactivateUseCase(cryptoCurrency) + modelScope.launch(NonCancellable) { + fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id) + } params.callback.onTransactionSent() }, ) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformer.kt index 0e3dbcac3d..4e44bb47e6 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformer.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformer.kt @@ -44,6 +44,7 @@ internal class YieldSupplyStopEarningFeeContentTransformer( ), currentNetworkFeeValue = TextReference.EMPTY, maxNetworkFeeValue = TextReference.EMPTY, + minAmountFeeValue = TextReference.EMPTY, ), ) } 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..9bac246d33 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -169,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-ethereum" + Blockchain.ArbitrumNova -> "arbitrum-nova-ethereum" } } diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt index 6008e6ea58..37c9b75e4d 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -3,35 +3,73 @@ 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 +import timber.log.Timber /** * Utility class to recognize the account node in a derivation path based on the blockchain type. * Derivation path schema: [ m / purpose' / coin_type' / account' / change / address_index ]. * - * @param blockchain the blockchain for which the account node is to be recognized + * @property blockchain the blockchain for which the account node is to be recognized * + * @see [iOS](https://github.com/tangem-developments/tangem-app-ios/blob/f5312a8177afbebda2ff2ed93771f11fae4837bb/Tangem/Domain/Accounts/Common/AccountDerivationPathHelper.swift) [REDACTED_AUTHOR] */ -class AccountNodeRecognizer(blockchain: Blockchain) { +class AccountNodeRecognizer(private val blockchain: Blockchain) { - /** Index of the account node in the derivation path */ - val accountNodeIndex: Int = if (blockchain.isUTXO) { - UTXO_BLOCKCHAIN_NODE_INDEX - } else { - NON_UTXO_BLOCKCHAIN_NODE_INDEX + /** + * Index of the account node in the [derivationPath] + */ + @Suppress("MagicNumber") + fun getAccountNodeIndex(derivationPath: DerivationPath): Int? { + val nodesCount = derivationPath.nodes.size + + val index = when { + blockchain == Blockchain.Tezos -> { + UTXO_BLOCKCHAIN_NODE_INDEX.takeIf { nodesCount == 4 } + } + blockchain == Blockchain.Quai || blockchain.isUTXO -> { + UTXO_BLOCKCHAIN_NODE_INDEX.takeIf { nodesCount == 5 } + } + !blockchain.isUTXO -> { + (nodesCount - 1).takeIf { nodesCount == 3 || nodesCount == 5 } + } + else -> null + } + + if (index == null) { + Timber.e("Cannot determine account node index for ${blockchain.fullName}: ${derivationPath.rawPath}") + } + + return 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] */ fun recognize(derivationPath: DerivationPath): Long? { return runCatching { + if (!blockchain.isAccountsSupported()) { + Timber.e("Account derivation is not supported for blockchain: ${blockchain.fullName}") + return null + } + + val accountNodeIndex = getAccountNodeIndex(derivationPath) ?: return null val accountNode = derivationPath.nodes.getOrNull(accountNodeIndex) accountNode?.getIndex(includeHardened = false) @@ -39,8 +77,180 @@ class AccountNodeRecognizer(blockchain: Blockchain) { .getOrNull() } + @Suppress("LongMethod") + private fun Blockchain.isAccountsSupported(): Boolean { + return when (this) { + Blockchain.Bitcoin, + Blockchain.Litecoin, + Blockchain.Stellar, + Blockchain.Ethereum, + Blockchain.EthereumPow, + Blockchain.Dischain, + Blockchain.EthereumClassic, + Blockchain.RSK, + Blockchain.BitcoinCash, + Blockchain.Binance, + Blockchain.Cardano, + Blockchain.XRP, + Blockchain.Ducatus, + Blockchain.Tezos, + Blockchain.Dogecoin, + Blockchain.BSC, + Blockchain.Polygon, + Blockchain.Avalanche, + Blockchain.Solana, + Blockchain.Fantom, + Blockchain.Polkadot, + Blockchain.Kusama, + Blockchain.AlephZero, + Blockchain.Tron, + Blockchain.Arbitrum, + Blockchain.Dash, + Blockchain.Gnosis, + Blockchain.Optimism, + Blockchain.TON, + Blockchain.Kava, + Blockchain.Kaspa, + Blockchain.Ravencoin, + Blockchain.Cosmos, + Blockchain.TerraV1, + Blockchain.TerraV2, + Blockchain.Cronos, + Blockchain.Telos, + Blockchain.OctaSpace, + Blockchain.Near, + Blockchain.Decimal, + Blockchain.VeChain, + Blockchain.XDC, + Blockchain.Algorand, + Blockchain.Shibarium, + Blockchain.Aptos, + Blockchain.Hedera, + Blockchain.Areon, + Blockchain.Playa3ull, + Blockchain.PulseChain, + Blockchain.Aurora, + Blockchain.Manta, + Blockchain.ZkSyncEra, + Blockchain.Moonbeam, + Blockchain.PolygonZkEVM, + Blockchain.Moonriver, + Blockchain.Mantle, + Blockchain.Flare, + Blockchain.Taraxa, + Blockchain.Radiant, + Blockchain.Base, + Blockchain.Joystream, + Blockchain.Bittensor, + Blockchain.Koinos, + Blockchain.InternetComputer, + Blockchain.Cyber, + Blockchain.Blast, + Blockchain.Sui, + Blockchain.Filecoin, + Blockchain.Sei, + Blockchain.EnergyWebChain, + Blockchain.EnergyWebX, + Blockchain.Core, + Blockchain.Canxium, + Blockchain.Casper, + Blockchain.Chiliz, + Blockchain.Xodex, + Blockchain.Clore, + Blockchain.Fact0rn, + Blockchain.OdysseyChain, + Blockchain.Bitrock, + Blockchain.ApeChain, + Blockchain.Sonic, + Blockchain.Alephium, + Blockchain.VanarChain, + Blockchain.ZkLinkNova, + Blockchain.Pepecoin, + Blockchain.Hyperliquid, + Blockchain.Scroll, + Blockchain.Linea, + Blockchain.ArbitrumNova, + Blockchain.Quai, + -> true + Blockchain.Nexa, // unsupported network + Blockchain.Chia, + -> false + // region Testnet + Blockchain.Unknown, + Blockchain.ArbitrumTestnet, + Blockchain.AvalancheTestnet, + Blockchain.BinanceTestnet, + Blockchain.BSCTestnet, + Blockchain.BitcoinTestnet, + Blockchain.BitcoinCashTestnet, + Blockchain.CosmosTestnet, + Blockchain.EthereumTestnet, + Blockchain.EthereumClassicTestnet, + Blockchain.FantomTestnet, + Blockchain.NearTestnet, + Blockchain.PolkadotTestnet, + Blockchain.KavaTestnet, + Blockchain.PolygonTestnet, + Blockchain.SeiTestnet, + Blockchain.StellarTestnet, + Blockchain.SolanaTestnet, + Blockchain.TronTestnet, + Blockchain.OptimismTestnet, + Blockchain.EthereumPowTestnet, + Blockchain.KaspaTestnet, + Blockchain.TelosTestnet, + Blockchain.TONTestnet, + Blockchain.RavencoinTestnet, + Blockchain.AlephZeroTestnet, + Blockchain.OctaSpaceTestnet, + Blockchain.ChiaTestnet, + Blockchain.DecimalTestnet, + Blockchain.XDCTestnet, + Blockchain.VeChainTestnet, + Blockchain.AptosTestnet, + Blockchain.ShibariumTestnet, + Blockchain.AlgorandTestnet, + Blockchain.HederaTestnet, + Blockchain.AuroraTestnet, + Blockchain.AreonTestnet, + Blockchain.PulseChainTestnet, + Blockchain.ZkSyncEraTestnet, + Blockchain.NexaTestnet, + Blockchain.MoonbeamTestnet, + Blockchain.MantaTestnet, + Blockchain.PolygonZkEVMTestnet, + Blockchain.BaseTestnet, + Blockchain.MoonriverTestnet, + Blockchain.MantleTestnet, + Blockchain.FlareTestnet, + Blockchain.TaraxaTestnet, + Blockchain.KoinosTestnet, + Blockchain.BlastTestnet, + Blockchain.CyberTestnet, + Blockchain.SuiTestnet, + Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebXTestnet, + Blockchain.CasperTestnet, + Blockchain.CoreTestnet, + Blockchain.ChilizTestnet, + Blockchain.AlephiumTestnet, + Blockchain.VanarChainTestnet, + Blockchain.OdysseyChainTestnet, + Blockchain.BitrockTestnet, + Blockchain.SonicTestnet, + Blockchain.ApeChainTestnet, + Blockchain.ScrollTestnet, + Blockchain.ZkLinkNovaTestnet, + Blockchain.PepecoinTestnet, + Blockchain.HyperliquidTestnet, + Blockchain.QuaiTestnet, + Blockchain.LineaTestnet, + -> false + // endregion + } + } + private companion object { const val UTXO_BLOCKCHAIN_NODE_INDEX = 2 - const val NON_UTXO_BLOCKCHAIN_NODE_INDEX = 4 } } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt index 92ec3a5e41..fe1f193f9a 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/MutableDerivationPath.kt @@ -24,8 +24,8 @@ class MutableDerivationPath internal constructor(val value: DerivationPath) { fun replaceAccountNode(value: Long, blockchain: Blockchain): MutableDerivationPath { val mutableNodes = this@MutableDerivationPath.value.nodes.toMutableList() - val accountNodeIndex = AccountNodeRecognizer(blockchain).accountNodeIndex - val accountNode = mutableNodes.getOrNull(accountNodeIndex) + val accountNodeIndex = AccountNodeRecognizer(blockchain).getAccountNodeIndex(this@MutableDerivationPath.value) + val accountNode = accountNodeIndex?.let(mutableNodes::getOrNull) if (accountNode != null) { mutableNodes[accountNodeIndex] = when (accountNode) { diff --git a/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt index 4295568a38..89b8cfd373 100644 --- a/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt +++ b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/AccountNodeRecognizerTest.kt @@ -9,13 +9,13 @@ import org.junit.jupiter.api.Test internal class AccountNodeRecognizerTest { private val utxoBlockchain = Blockchain.Bitcoin - private val nonUtxoBlockchain = Blockchain.Ethereum + private val ethLikeBlockchain = Blockchain.Ethereum @Nested inner class RecognizeAsDerivationPath { @Test - fun `returns account node value for UTXO blockchain`() { + fun `returns account node value for a derivation path with 5 nodes and UTXO blockchain`() { // Arrange val recognizer = AccountNodeRecognizer(utxoBlockchain) val derivationPath = DerivationPath(rawPath = "m/44'/0'/1'/0/0") @@ -29,10 +29,104 @@ internal class AccountNodeRecognizerTest { } @Test - fun `returns account node value for non-UTXO blockchain`() { + fun `returns account node value for a derivation path with 4 nodes and UTXO blockchain`() { // Arrange - val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) - val derivationPath = DerivationPath(rawPath = "m/44'/0'/0'/0/0") + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/1'/0") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 5 nodes and non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/1'/2/3") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 3 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for a derivation path with 4 nodes and non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/1'/2") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 3 nodes and non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/1'") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 1 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for a derivation path with 4 nodes and Tezos blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Tezos) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0/5'") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + val expected = 0 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for a derivation path with 5 nodes and Tezos blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Tezos) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0'/5/1") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 4 nodes and Quai blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Quai) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0/5'") + + // Act + val actual = recognizer.recognize(derivationPath) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 5 nodes and Quai blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Quai) + val derivationPath = DerivationPath(rawPath = "m/44'/0'/0'/5/1") // Act val actual = recognizer.recognize(derivationPath) @@ -45,7 +139,7 @@ internal class AccountNodeRecognizerTest { @Test fun `returns null if derivation path is shorter than expected`() { // Arrange - val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) val derivationPath = DerivationPath(rawPath = "m/44'/0'") // Act @@ -60,13 +154,13 @@ internal class AccountNodeRecognizerTest { inner class RecognizeAsString { @Test - fun `returns account node value for UTXO blockchain`() { + fun `returns account node value for a derivation path with 5 nodes and UTXO blockchain`() { // Arrange val recognizer = AccountNodeRecognizer(utxoBlockchain) - val derivationPath = "m/44'/0'/1'/0/0" + val derivationPathValue = "m/44'/0'/1'/0/0" // Act - val actual = recognizer.recognize(derivationPath) + val actual = recognizer.recognize(derivationPathValue) // Assert val expected = 1 @@ -74,13 +168,107 @@ internal class AccountNodeRecognizerTest { } @Test - fun `returns account node value for non-UTXO blockchain`() { + fun `returns account node value for a derivation path with 4 nodes and UTXO blockchain`() { // Arrange - val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) - val derivationPath = "m/44'/0'/0'/0/0" + val recognizer = AccountNodeRecognizer(utxoBlockchain) + val derivationPathValue = "m/44'/0'/1'/0" // Act - val actual = recognizer.recognize(derivationPath) + val actual = recognizer.recognize(derivationPathValue) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 5 nodes and non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) + val derivationPathValue = "m/44'/0'/1'/2/3" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + val expected = 3 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for a derivation path with 4 nodes and non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) + val derivationPathValue = "m/44'/0'/1'/2" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 3 nodes and non-UTXO blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) + val derivationPathValue = "m/44'/0'/1'" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + val expected = 1 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for a derivation path with 4 nodes and Tezos blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Tezos) + val derivationPathValue = "m/44'/0'/0/5'" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + val expected = 0 + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `returns account node value for a derivation path with 5 nodes and Tezos blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Tezos) + val derivationPathValue = "m/44'/0'/0'/5/1" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 4 nodes and Quai blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Quai) + val derivationPathValue = "m/44'/0'/0/5'" + + // Act + val actual = recognizer.recognize(derivationPathValue) + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `returns account node value for a derivation path with 5 nodes and Quai blockchain`() { + // Arrange + val recognizer = AccountNodeRecognizer(Blockchain.Quai) + val derivationPathValue = "m/44'/0'/0'/5/1" + + // Act + val actual = recognizer.recognize(derivationPathValue) // Assert val expected = 0 @@ -90,11 +278,11 @@ internal class AccountNodeRecognizerTest { @Test fun `returns null if derivation path is shorter than expected`() { // Arrange - val recognizer = AccountNodeRecognizer(nonUtxoBlockchain) - val derivationPath = "m/44'/0'" + val recognizer = AccountNodeRecognizer(ethLikeBlockchain) + val derivationPathValue = "m/44'/0'" // Act - val actual = recognizer.recognize(derivationPath) + val actual = recognizer.recognize(derivationPathValue) // Assert Truth.assertThat(actual).isNull() 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")